├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yaml │ ├── check_pr.yaml │ ├── close_stale_issues.yaml │ └── release.yaml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── chaoslib ├── __init__.py ├── activity.py ├── caching.py ├── configuration.py ├── control │ ├── __init__.py │ └── python.py ├── deprecation.py ├── discovery │ ├── __init__.py │ ├── discover.py │ └── package.py ├── exceptions.py ├── exit.py ├── experiment.py ├── extension.py ├── hypothesis.py ├── info.py ├── loader.py ├── log.py ├── notification.py ├── provider │ ├── __init__.py │ ├── http.py │ ├── process.py │ └── python.py ├── rollback.py ├── run.py ├── secret.py ├── settings.py └── types.py ├── pdm.lock ├── pyproject.toml └── tests ├── conftest.py ├── fixtures ├── __init__.py ├── actions.py ├── badstuff.py ├── config.py ├── configprobe.py ├── controls │ ├── __init__.py │ ├── dummy.py │ ├── dummy_args_in_control_init.py │ ├── dummy_changed_configuration.py │ ├── dummy_changed_secrets.py │ ├── dummy_fail_loading_experiment.py │ ├── dummy_need_access_to_end_state.py │ ├── dummy_position_1.py │ ├── dummy_position_2.py │ ├── dummy_position_3.py │ ├── dummy_retitle_experiment_on_loading.py │ ├── dummy_sums.py │ ├── dummy_validator.py │ ├── dummy_with_decorated_control.py │ ├── dummy_with_exited_activity.py │ ├── dummy_with_experiment.py │ ├── dummy_with_failing_cleanup.py │ ├── dummy_with_failing_init.py │ ├── dummy_with_interrupted_activity.py │ ├── dummy_with_secrets.py │ └── interrupter.py ├── env_vars_issue252.json ├── experiments.py ├── fakeext.py ├── interrupter.py ├── interruptexperiment.py ├── keepempty.py ├── longpythonfunc.py ├── notifier.py ├── probes.py ├── run_handlers.py ├── settings.yaml └── unsafe-settings.yaml ├── test_action.py ├── test_configuration.py ├── test_control.py ├── test_deprecation.py ├── test_discover.py ├── test_exit.py ├── test_experiment.py ├── test_extension.py ├── test_hash.py ├── test_info.py ├── test_loader.py ├── test_notification.py ├── test_payload_encoder.py ├── test_probe.py ├── test_process_provider.py ├── test_run.py ├── test_secret.py ├── test_settings.py ├── test_substitution.py ├── test_tolerance.py └── test_utils.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: pending-review 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Runtime versions** 14 | Please run: 15 | 16 | ``` 17 | $ python --version 18 | $ chaos info core 19 | $ chaos info extensions 20 | ``` 21 | 22 | **To Reproduce** 23 | Steps to reproduce the behavior. 24 | 25 | **Expected behavior** 26 | A clear and concise description of what you expected to happen. 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: pending-review 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 15 | os: ["ubuntu-latest", "macos-latest"] 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up PDM 22 | uses: pdm-project/setup-pdm@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | cache: true 26 | prerelease: true 27 | 28 | - name: Ensure lock file is up to date 29 | run: | 30 | pdm lock --check 31 | 32 | - name: Install dependencies 33 | run: | 34 | pdm sync -d 35 | 36 | - name: Run Lint 37 | run: | 38 | pdm run lint 39 | 40 | - name: Run Tests 41 | run: | 42 | pdm run pytest -------------------------------------------------------------------------------- /.github/workflows/check_pr.yaml: -------------------------------------------------------------------------------- 1 | name: Check PR 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | check-changelog: 8 | runs-on: ubuntu-22.04 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Check Changelog modified 12 | uses: dangoslen/changelog-enforcer@v2 13 | with: 14 | changeLogPath: 'CHANGELOG.md' 15 | missingUpdateErrorMessage: | 16 | Please include an entry into `CHANGELOG.md` to describe what happened in the PR 17 | check-tests: 18 | runs-on: ubuntu-22.04 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Get changed files 22 | id: files 23 | uses: jitterbit/get-changed-files@v1 24 | - name: Determine if Tests modified 25 | id: modified 26 | run: | 27 | for changed_file in ${{ steps.files.outputs.all }}; do 28 | if [[ ${changed_file} =~ ^tests\/.*test_.*\.py$ ]]; 29 | then 30 | echo "::set-output name=MODIFIED::TRUE"; # Tests were modified 31 | exit 0 32 | fi 33 | done 34 | echo "::set-output name=MODIFIED::FALSE" # Tests were not modified 35 | exit 0 36 | - name: Comment on PR 37 | if: ${{ steps.modified.outputs.MODIFIED == 'FALSE' }} 38 | uses: JoseThen/comment-pr@v1.1.0 39 | with: 40 | comment: | 41 | ## :warning: Tests not modified :warning: 42 | We've noticed your Pull Request did not modify any tests :mag: 43 | 44 | If your change requires tests, please add them :smile: 45 | 46 | You'll notice that the `check-tests` step passed with :white_check_mark:, this 47 | is not a confirmation that you've modified tests. 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /.github/workflows/close_stale_issues.yaml: -------------------------------------------------------------------------------- 1 | name: Close Stale Issues 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | # Every Sunday at 00:00 6 | - cron: '0 0 * * 0' 7 | 8 | jobs: 9 | close-stale-issues: 10 | permissions: 11 | issues: write 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - uses: actions/stale@v4 15 | with: 16 | operations-per-run: 200 17 | stale-issue-message: 'This Issue has not been active in 365 days. To re-activate this Issue, remove the `Stale` label or comment on it. If not re-activated, this Issue will be closed in 7 days.' 18 | close-issue-message: 'This Issue was closed because it was not reactivated after 7 days of being marked `Stale`.' 19 | days-before-issue-stale: 365 20 | days-before-issue-close: 7 21 | # For now, don't mark PRs stale and don't close them 22 | days-before-pr-stale: -1 23 | days-before-pr-close: -1 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | branches-ignore: 6 | - 'master' 7 | push: 8 | tags: 9 | - '[0-9]+.[0-9]+.[0-9]+' 10 | - '[0-9]+.[0-9]+.[0-9]+rc[0-9]+' 11 | 12 | jobs: 13 | release-to-pypi: 14 | runs-on: ubuntu-22.04 15 | environment: release 16 | permissions: 17 | id-token: write 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up PDM 21 | uses: pdm-project/setup-pdm@v4 22 | with: 23 | cache: true 24 | - name: Build 25 | run: pdm build 26 | env: 27 | PDM_BUILD_SCM_VERSION: ${{github.ref_name}} 28 | - name: Publish package distributions to PyPI 29 | uses: pypa/gh-action-pypi-publish@release/v1 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | #vscode 105 | .vscode/ 106 | 107 | # generated docs 108 | docs/site/ 109 | 110 | # pytest results 111 | junit-test-results.xml 112 | 113 | # Misc from Mac OS 114 | .DS_Store 115 | 116 | # Pycharm 117 | .idea 118 | 119 | # PDM 120 | .pdm-build/ 121 | .pdm-python 122 | 123 | 124 | # Ruff 125 | .ruff_cache/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Chaos Toolkit Code Of Conduct 2 | 3 | Chaos Toolkit adopts Debian project's Code Of Conduct for its 4 | participants and contributors. 5 | 6 | ## Be respectful 7 | 8 | In the Chaos Toolkit project, inevitably there will be people with whom you 9 | may disagree, or find it difficult to cooperate. Accept that, but even 10 | so, remain respectful. Disagreement is no excuse for poor behaviour or 11 | personal attacks, and a community in which people feel threatened is 12 | not a healthy community. 13 | 14 | ## Assume good faith 15 | 16 | Chaos Toolkit contributors have many ways of reaching our common goal 17 | of an open API for Chaos Engineering which may differ from your 18 | ways. Assume that other people are working towards this goal. 19 | 20 | Note that many of our Contributors are from around the globe, not 21 | native English speakers and may have different cultural backgrounds. 22 | 23 | ## Be Collaborative 24 | 25 | Chaos Toolkit is a large project; there is always more to learn within 26 | it. It's good to ask for help when you need it. Similarly, offers for 27 | help should be seen in the context of our shared goal of improving 28 | Chaos Toolkit. 29 | 30 | When you make something for the benefit of the project, be willing to 31 | explain to others how it works, so that they can build on your work to 32 | make it even better. 33 | 34 | ## Try to be concise 35 | 36 | Keep in mind that what you write once will be read by hundreds of 37 | people. Writing a short email means people can understand the 38 | conversation as efficiently as possible. When a long explanation is 39 | necessary, consider adding a summary. 40 | 41 | Try to bring new arguments to a conversation so that each mail adds 42 | something unique to the thread, keeping in mind that the rest of the 43 | thread still contains the other messages with arguments that have 44 | already been made. 45 | 46 | Try to stay on topic, especially in discussions that are already fairly large. 47 | 48 | ## Be open 49 | 50 | Most ways of communication used within Chaos Toolkit allow for public 51 | and private communication. You should use public methods of 52 | communication for Chaos Toolkit related messages, unless posting 53 | something sensitive. 54 | 55 | ## In case of problems 56 | 57 | While this code of conduct should be adhered to by participants, we 58 | recognize that sometimes people may have a bad day, or be unaware of 59 | some of the guidelines in this code of conduct. When that happens, you 60 | may reply to them and point out this code of conduct. Instances of 61 | otherwise unacceptable behavior may be reported by contacting team at 62 | contact@chaostoolkit.org. All complaints will be reviewed and 63 | investigated and will result in a response that is appropriate to the 64 | circumstances. 65 | 66 | 67 | ## Attribution 68 | 69 | This Code of Conduct is adapted from the [Debian Code Of Conduct][homepage], version 1.0 70 | 71 | [homepage]: https://www.debian.org/code_of_conduct 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 |
4 |

5 |

6 | 7 |

Chaos Toolkit - Chaos Engineering for Everyone

8 | 9 |

10 | 11 | Release 12 | 13 | Build 14 | 15 | GitHub issues 16 | 17 | License 18 | 19 | Python version 20 | 21 |

22 | 23 |

24 | Community • 25 | ChangeLog 26 |

27 | 28 | --- 29 | 30 | ## Purpose 31 | 32 | The purpose of this library is to provide the core of the Chaos Toolkit 33 | [model][concepts] and functions it needs to render its services. 34 | 35 | [concepts]: https://docs.chaostoolkit.org/reference/concepts/ 36 | 37 | ## Features 38 | 39 | The library provides the followings features: 40 | 41 | * discover capabilities from extensions 42 | Allows you to explore the support from an extension that would help you 43 | initialize an experiment against the system this extension targets 44 | 45 | * validate a given experiment syntax 46 | The validation looks at various keys in the experiment and raises errors 47 | whenever something doesn't look right. 48 | As a nice addition, when a probe calls a Python function with arguments, 49 | it tries to validate the given argument list matches the signature of the 50 | function to apply. 51 | 52 | * run your steady state before and after the method. The former as a gate to 53 | decide if the experiment can be executed. The latter to see if the system 54 | deviated from normal. 55 | 56 | * run probes and actions declared in an experiment 57 | It runs the steps in a experiment method sequentially, applying first steady 58 | probes, then actions and finally close probes. 59 | 60 | A journal, as a JSON payload, is return of the experiment run. 61 | 62 | The library supports running probes and actions defined as Python functions, 63 | from importable Python modules, processes and HTTP calls. 64 | 65 | * run experiment's rollbacks when provided 66 | 67 | * Load secrets from the experiments, the environ or [vault][vault] 68 | 69 | * Provides event notification from Chaos Toolkit flow (although the actual 70 | events are published by the CLI itself, not from this library), supported 71 | events are: 72 | * on experiment validation: started, failed or completed 73 | * on discovery: started, failed or completed 74 | * on initialization of experiments: started, failed or completed 75 | * on experiment runs: started, failed or completed 76 | 77 | For each event, the according payload is part of the event as well as a UTC 78 | timestamp. 79 | 80 | [vault]: https://www.vaultproject.io/ 81 | 82 | ## Install 83 | 84 | If you are user of the Chaos Toolkit, you probably do not need to install this 85 | package yourself as it comes along with the [chaostoolkit cli][cli]. 86 | 87 | [cli]: https://github.com/chaostoolkit/chaostoolkit 88 | 89 | However, should you wish to integrate this library in your own Python code, 90 | please install it as usual: 91 | 92 | ``` 93 | $ pip install -U chaostoolkit-lib 94 | ``` 95 | 96 | ### Specific dependencies 97 | 98 | In addition to essential dependencies, the package can install a couple of 99 | other extra dependencies for specific use-cases. They are not mandatory and 100 | the library will warn you if you try to use a feature that requires them. 101 | 102 | ### Vault 103 | 104 | If you need [Vault][vault] support to read secrets from, run the following 105 | command: 106 | 107 | [vault]: https://www.vaultproject.io/ 108 | ``` 109 | $ pip install -U chaostoolkit-lib[vault] 110 | ``` 111 | 112 | To authenticate with Vault, you can either: 113 | * Use a token through the `vault_token` configuration key 114 | * Use an [AppRole][approle] via the `vault_role_id`, `vault_secret_id` pair of configuration keys 115 | * Use a [service account][serviceaccount] configured with an appropriate [role][role] via the `vault_sa_role` configuration key. The `vault_sa_token_path`, `vault_k8s_mount_point`, and `vault_secrets_mount_point` configuration keys can optionally be specified to point to a location containing a service account [token][sa-token], a different Kubernetes authentication method [mount point][k8s-mount], or a different secrets [mount point][secrets-mount], respectively. 116 | 117 | [approle]: https://www.vaultproject.io/docs/auth/approle.html 118 | [serviceaccount]: https://www.vaultproject.io/api/auth/kubernetes/index.html 119 | [role]: https://www.vaultproject.io/api/auth/kubernetes/index.html#create-role 120 | [sa-token]: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#service-account-token-volume-projection 121 | [k8s-mount]: https://www.vaultproject.io/docs/auth/kubernetes.html 122 | [secrets-mount]: https://www.vaultproject.io/api/secret/kv/kv-v1.htm 123 | 124 | 125 | ### JSON Path 126 | 127 | If you need [JSON Path support][jpath] for tolerance probes in the hypothesis, 128 | also run the following command: 129 | 130 | [jpath]: http://goessner.net/articles/JsonPath/ 131 | 132 | ``` 133 | $ pip install -U chaostoolkit-lib[jsonpath] 134 | ``` 135 | 136 | ## Contribute 137 | 138 | Contributors to this project are welcome as this is an open-source effort that 139 | seeks [discussions][join] and continuous improvement. 140 | 141 | [join]: https://join.chaostoolkit.org/ 142 | 143 | From a code perspective, if you wish to contribute, you will need to run a 144 | Python 3.6+ environment. Please, fork this project, write unit tests to cover 145 | the proposed changes, implement the changes, ensure they meet the formatting 146 | standards set out by `black`, `flake8`, and `isort`, add an entry into 147 | `CHANGELOG.md`, and then raise a PR to the repository for review. 148 | 149 | Please refer to the [formatting](#formatting-and-linting) section for more 150 | information on the formatting standards. 151 | 152 | The Chaos Toolkit projects require all contributors must sign a 153 | [Developer Certificate of Origin][dco] on each commit they would like to merge 154 | into the master branch of the repository. Please, make sure you can abide by 155 | the rules of the DCO before submitting a PR. 156 | 157 | [dco]: https://github.com/probot/dco#how-it-works 158 | 159 | 160 | ### Develop 161 | 162 | If you wish to develop on this project, make sure to install the development 163 | dependencies. To do so, first install [pdm](https://pdm-project.org/latest/). 164 | 165 | 166 | ```console 167 | $ pdm install --dev 168 | ``` 169 | 170 | Now, you can edit the files and they will be automatically be seen by your 171 | environment, even when running from the `chaos` command locally. 172 | 173 | ### Test 174 | 175 | To run the tests for the project execute the following: 176 | 177 | ``` 178 | $ pdm run test 179 | ``` 180 | 181 | ### Formatting and Linting 182 | 183 | We use [ruff]() to perform linting and code style. 184 | 185 | [ruff]: https://astral.sh/ruff 186 | 187 | Before raising a Pull Request, we recommend you run formatting against your 188 | code with: 189 | 190 | ```console 191 | $ pdm run format 192 | ``` 193 | 194 | This will automatically format any code that doesn't adhere to the formatting 195 | standards. 196 | 197 | As some things are not picked up by the formatting, we also recommend you run: 198 | 199 | ```console 200 | $ pdm run lint 201 | ``` 202 | 203 | To ensure that any unused import statements/strings that are too long, etc. 204 | are also picked up. 205 | -------------------------------------------------------------------------------- /chaoslib/caching.py: -------------------------------------------------------------------------------- 1 | # Builds an in-memory cache of all declared activities so they can be 2 | # referenced from other places in the experiment 3 | import inspect 4 | import logging 5 | from functools import wraps 6 | from typing import Any, Dict, List, Union 7 | 8 | import chaoslib 9 | from chaoslib.types import Activity, Experiment, Schedule, Settings, Strategy 10 | 11 | __all__ = ["cache_activities", "clear_cache", "lookup_activity", "with_cache"] 12 | 13 | 14 | # global objects are frown upon but as we write to it once 15 | # (from a single place) and we only read afterwards, that's likely okay. 16 | _cache = {} 17 | logger = logging.getLogger("chaostoolkit") 18 | 19 | 20 | def cache_activities(experiment: Experiment) -> List[Activity]: 21 | """ 22 | Cache all activities into a map so we can quickly lookup ref. 23 | """ 24 | logger.debug("Building activity cache...") 25 | 26 | lot = experiment.get("method", []) + experiment.get( 27 | "steady-state-hypothesis", {} 28 | ).get("probes", []) 29 | 30 | for activity in lot: 31 | name = activity.get("name") 32 | if name: 33 | _cache[name] = activity 34 | 35 | logger.debug(f"Cached {len(_cache)} activities") 36 | 37 | 38 | def clear_cache(): 39 | """ 40 | Clear the cache 41 | """ 42 | logger.debug("Clearing activities cache") 43 | _cache.clear() 44 | 45 | 46 | def with_cache(f): 47 | """ 48 | Ensure the activities cache is populated before calling the wrapped 49 | function. 50 | """ 51 | 52 | @wraps(f) 53 | def wrapped( 54 | experiment: Experiment, 55 | settings: Settings = None, 56 | experiment_vars: Dict[str, Any] = None, 57 | strategy: Strategy = Strategy.DEFAULT, 58 | schedule: Schedule = None, 59 | event_handlers: List[chaoslib.run.RunEventHandler] = None, 60 | ): 61 | try: 62 | if experiment: 63 | cache_activities(experiment) 64 | 65 | sig = inspect.signature(f) 66 | arguments = {"experiment": experiment} 67 | 68 | if "settings" in sig.parameters: 69 | arguments["settings"] = settings 70 | if "experiment_vars" in sig.parameters: 71 | arguments["experiment_vars"] = experiment_vars 72 | if "strategy" in sig.parameters: 73 | arguments["strategy"] = strategy 74 | if "schedule" in sig.parameters: 75 | arguments["schedule"] = schedule 76 | if "event_handlers" in sig.parameters: 77 | arguments["event_handlers"] = event_handlers 78 | return f(**arguments) 79 | finally: 80 | clear_cache() 81 | 82 | return wrapped 83 | 84 | 85 | def lookup_activity(ref: str) -> Union[Activity, None]: 86 | """ 87 | Lookup an activity by name and return it or `None`. 88 | """ 89 | activity = _cache.get(ref) 90 | if not activity: 91 | logger.debug(f"cache miss for '{ref}'") 92 | return activity 93 | -------------------------------------------------------------------------------- /chaoslib/configuration.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from copy import deepcopy 4 | from typing import Any, Dict 5 | 6 | from chaoslib import convert_to_type 7 | from chaoslib.exceptions import InvalidExperiment 8 | from chaoslib.types import Configuration, Secrets 9 | 10 | __all__ = ["load_configuration", "load_dynamic_configuration"] 11 | 12 | logger = logging.getLogger("chaostoolkit") 13 | 14 | 15 | def load_configuration( 16 | config_info: Dict[str, str], extra_vars: Dict[str, Any] = None 17 | ) -> Configuration: 18 | """ 19 | Load the configuration. The `config_info` parameter is a mapping from 20 | key strings to value as strings or dictionaries. In the former case, the 21 | value is used as-is. In the latter case, if the dictionary has a key named 22 | `type` alongside a key named `key`. 23 | An optional default value is accepted for dictionary value with a key named 24 | `default`. The default value will be used only if the environment variable 25 | is not defined. 26 | 27 | 28 | Here is a sample of what it looks like: 29 | 30 | ``` 31 | { 32 | "cert": "/some/path/file.crt", 33 | "token": { 34 | "type": "env", 35 | "key": "MY_TOKEN" 36 | }, 37 | "host": { 38 | "type": "env", 39 | "key": "HOSTNAME", 40 | "default": "localhost" 41 | }, 42 | "port": { 43 | "type": "env", 44 | "key": "SERVICE_PORT", 45 | "env_var_type": "int" 46 | } 47 | } 48 | ``` 49 | 50 | The `cert` configuration key is set to its string value whereas the `token` 51 | configuration key is dynamically fetched from the `MY_TOKEN` environment 52 | variable. The `host` configuration key is dynamically fetched from the 53 | `HOSTNAME` environment variable, but if not defined, the default value 54 | `localhost` will be used instead. The `port` configuration key is 55 | dynamically fetched from the `SERVICE_PORT` environment variable. It is 56 | coerced into an `int` with the addition of the `env_var_type` key. 57 | 58 | When `extra_vars` is provided, it must be a dictionnary where keys map 59 | to configuration key. The values from `extra_vars` always override the 60 | values from the experiment itself. This is useful to the Chaos Toolkit 61 | CLI mostly to allow overriding values directly from cli arguments. It's 62 | seldom required otherwise. 63 | """ 64 | logger.debug("Loading configuration...") 65 | env = os.environ 66 | extra_vars = extra_vars or {} 67 | conf = {} 68 | 69 | for key, value in config_info.items(): 70 | # env var files can contain the full definition of the 71 | # configuration's key, so we swap it. See #252 72 | if key in extra_vars: 73 | value = extra_vars.pop(key, None) 74 | 75 | if isinstance(value, dict) and "type" in value: 76 | if value["type"] == "env": 77 | env_key = value["key"] 78 | env_default = value.get("default") 79 | if ( 80 | (env_key not in env) 81 | and ("default" not in value) 82 | and (key not in extra_vars) 83 | ): 84 | raise InvalidExperiment( 85 | "Configuration makes reference to an environment key" 86 | " that does not exist: {}".format(env_key) 87 | ) 88 | env_var_type = value.get("env_var_type") 89 | env_var_value = convert_to_type( 90 | env_var_type, env.get(env_key, env_default) 91 | ) 92 | conf[key] = extra_vars.get(key, env_var_value) 93 | else: 94 | conf[key] = extra_vars.get(key, value) 95 | 96 | else: 97 | conf[key] = extra_vars.get(key, value) 98 | 99 | return conf 100 | 101 | 102 | def load_dynamic_configuration( 103 | config: Configuration, secrets: Secrets = None 104 | ) -> Configuration: 105 | """ 106 | This is for loading a dynamic configuration if exists. 107 | The dynamic config is a regular activity (probe) in the configuration 108 | section. If there's a use-case for setting a configuration dynamically 109 | right before the experiment is starting. It executes the probe, 110 | and then the return value of this probe will be the config you wish to set. 111 | The dictionary needs to have a key named `type` and as a value `probe`, 112 | alongside the rest of the probe props. 113 | (No need for the `tolerance` key). 114 | 115 | For example: 116 | 117 | ```json 118 | "some_dynamic_config": { 119 | "name": "some config probe", 120 | "type": "probe", 121 | "provider": { 122 | "type": "python", 123 | "module": "src.probes", 124 | "func": "config_probe", 125 | "arguments": { 126 | "arg1":"arg1" 127 | } 128 | } 129 | } 130 | ``` 131 | 132 | `some_dynamic_config` will be set with the return value 133 | of the function config_probe. 134 | 135 | Side Note: the probe type can be the same as a regular probe can be, 136 | python, process or http. The config argument contains all the 137 | configurations of the experiment including the raw config_probe 138 | configuration that can be dynamically injected. 139 | 140 | The configurations contain as well all the env vars after they are set in 141 | `load_configuration`. 142 | 143 | The `secrets` argument contains all the secrets of the experiment. 144 | 145 | For `process` probes, the stdout value (stripped of endlines) 146 | is stored into the configuration. 147 | For `http` probes, the `body` value is stored. 148 | For `python` probes, the output of the function will be stored. 149 | 150 | We do not stop on errors but log a debug message and do not include the 151 | key into the result dictionary. 152 | """ 153 | # we delay this so that the configuration module can be imported leanly 154 | # from elsewhere 155 | from chaoslib.activity import run_activity 156 | 157 | conf = {} 158 | secrets = secrets or {} 159 | 160 | had_errors = False 161 | logger.debug("Loading dynamic configuration...") 162 | for key, value in config.items(): 163 | if not (isinstance(value, dict) and value.get("type") == "probe"): 164 | conf[key] = config.get(key, value) 165 | continue 166 | 167 | # we have a dynamic config 168 | name = value.get("name") 169 | provider_type = value["provider"]["type"] 170 | value["provider"]["secrets"] = deepcopy(secrets) 171 | try: 172 | output = run_activity(value, conf, secrets) 173 | except Exception: 174 | had_errors = True 175 | logger.debug( 176 | f"Failed to load configuration '{name}'", exc_info=True 177 | ) 178 | continue 179 | 180 | if provider_type == "python": 181 | conf[key] = output 182 | elif provider_type == "process": 183 | if output["status"] != 0: 184 | had_errors = True 185 | logger.debug( 186 | f"Failed to load configuration dynamically " 187 | f"from probe '{name}': {output['stderr']}" 188 | ) 189 | else: 190 | conf[key] = output.get("stdout", "").strip() 191 | elif provider_type == "http": 192 | conf[key] = output.get("body") 193 | 194 | if had_errors: 195 | logger.warning( 196 | "Some of the dynamic configuration failed to be loaded." 197 | "Please review the log file for understanding what happened." 198 | ) 199 | 200 | return conf 201 | -------------------------------------------------------------------------------- /chaoslib/control/python.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import logging 4 | from copy import deepcopy 5 | from typing import Any, Callable, List, Optional, Union 6 | 7 | from chaoslib import substitute 8 | from chaoslib.exceptions import InvalidActivity 9 | from chaoslib.types import ( 10 | Activity, 11 | Configuration, 12 | Control, 13 | Experiment, 14 | Journal, 15 | Run, 16 | Secrets, 17 | Settings, 18 | ) 19 | 20 | __all__ = [ 21 | "apply_python_control", 22 | "cleanup_control", 23 | "initialize_control", 24 | "validate_python_control", 25 | "import_control", 26 | ] 27 | logger = logging.getLogger("chaostoolkit") 28 | _level_mapping = { 29 | "experiment-before": "before_experiment_control", 30 | "experiment-after": "after_experiment_control", 31 | "hypothesis-before": "before_hypothesis_control", 32 | "hypothesis-after": "after_hypothesis_control", 33 | "method-before": "before_method_control", 34 | "method-after": "after_method_control", 35 | "rollback-before": "before_rollback_control", 36 | "rollback-after": "after_rollback_control", 37 | "activity-before": "before_activity_control", 38 | "activity-after": "after_activity_control", 39 | "loader-before": "before_loading_experiment_control", 40 | "loader-after": "after_loading_experiment_control", 41 | } 42 | 43 | 44 | def import_control(control: Control) -> Optional[Any]: 45 | """ 46 | Import the module implementing a control. 47 | """ 48 | provider = control["provider"] 49 | mod_path = provider["module"] 50 | try: 51 | return importlib.import_module(mod_path) 52 | except ImportError: 53 | logger.debug( 54 | "Control module '{}' could not be loaded. " 55 | "Have you installed it?".format(mod_path) 56 | ) 57 | 58 | 59 | def initialize_control( 60 | control: Control, 61 | experiment: Experiment, 62 | configuration: Configuration, 63 | secrets: Secrets, 64 | settings: Settings = None, 65 | event_registry: "EventHandlerRegistry" = None, # noqa: F821 66 | ): 67 | """ 68 | Initialize a control by calling its `configure_control` function. 69 | """ 70 | func = load_func(control, "configure_control") 71 | if not func: 72 | return 73 | 74 | provider = control["provider"] 75 | arguments = deepcopy(provider.get("arguments", {})) 76 | sig = inspect.signature(func) 77 | 78 | if "experiment" in sig.parameters: 79 | arguments["experiment"] = experiment 80 | 81 | if "secrets" in sig.parameters: 82 | arguments["secrets"] = secrets 83 | 84 | if "configuration" in sig.parameters: 85 | arguments["configuration"] = configuration 86 | 87 | if "settings" in sig.parameters: 88 | arguments["settings"] = settings 89 | 90 | if "event_registry" in sig.parameters: 91 | arguments["event_registry"] = event_registry 92 | 93 | func(**arguments) 94 | 95 | 96 | def cleanup_control(control: Control): 97 | """ 98 | Cleanup a control by calling its `cleanup_control` function. 99 | """ 100 | func = load_func(control, "cleanup_control") 101 | if not func: 102 | return 103 | func() 104 | 105 | 106 | def validate_python_control(control: Control): 107 | """ 108 | Verify that a control block matches the specification 109 | """ 110 | name = control["name"] 111 | provider = control["provider"] 112 | mod_name = provider.get("module") 113 | if not mod_name: 114 | raise InvalidActivity(f"Control '{name}' must have a module path") 115 | 116 | try: 117 | importlib.import_module(mod_name) 118 | except ImportError: 119 | logger.warning( 120 | "Could not find Python module '{mod}' " 121 | "in control '{name}'. Did you install the Python " 122 | "module? The experiment will carry on running " 123 | "nonetheless.".format(mod=mod_name, name=name) 124 | ) 125 | 126 | # a control can validate itself too 127 | # ideally, do it cleanly and raise chaoslib.exceptions.InvalidActivity 128 | func = load_func(control, "validate_control") 129 | if not func: 130 | return 131 | 132 | func(control) 133 | 134 | 135 | def apply_python_control( 136 | level: str, 137 | control: Control, # noqa: C901 138 | experiment: Experiment, 139 | context: Union[Activity, Experiment], 140 | state: Union[Journal, Run, List[Run]] = None, 141 | configuration: Configuration = None, 142 | secrets: Secrets = None, 143 | settings: Settings = None, 144 | ): 145 | """ 146 | Apply a control by calling a function matching the given level. 147 | """ 148 | provider = control["provider"] 149 | func_name = _level_mapping.get(level) 150 | func = load_func(control, func_name) 151 | if not func: 152 | return 153 | 154 | arguments = deepcopy(provider.get("arguments", {})) 155 | 156 | if configuration or secrets: 157 | arguments = substitute(arguments, configuration, secrets) 158 | 159 | sig = inspect.signature(func) 160 | 161 | if "secrets" in sig.parameters: 162 | arguments["secrets"] = secrets 163 | 164 | if "configuration" in sig.parameters: 165 | arguments["configuration"] = configuration 166 | 167 | if "state" in sig.parameters: 168 | arguments["state"] = state 169 | 170 | if "experiment" in sig.parameters: 171 | arguments["experiment"] = experiment 172 | 173 | if "extensions" in sig.parameters: 174 | arguments["extensions"] = experiment.get("extensions") 175 | 176 | if "settings" in sig.parameters: 177 | arguments["settings"] = settings 178 | 179 | func(context=context, **arguments) 180 | 181 | 182 | ############################################################################### 183 | # Internals 184 | ############################################################################### 185 | def load_func(control: Control, func_name: str) -> Callable: 186 | mod = import_control(control) 187 | if not mod: 188 | return 189 | 190 | func = getattr(mod, func_name, None) 191 | if not func: 192 | logger.debug( 193 | f"Control module '{mod.__file__}' does not declare '{func_name}'" 194 | ) 195 | return 196 | 197 | try: 198 | logger.debug( 199 | f"Control '{func_name}' loaded from '{inspect.getfile(func)}'" 200 | ) 201 | except TypeError: 202 | pass 203 | 204 | return func 205 | -------------------------------------------------------------------------------- /chaoslib/deprecation.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | 4 | from chaoslib.activity import get_all_activities_in_experiment 5 | from chaoslib.types import Experiment 6 | 7 | __all__ = ["warn_about_deprecated_features", "warn_about_moved_function"] 8 | logger = logging.getLogger("chaostoolkit") 9 | 10 | DeprecatedDictArgsMessage = ( 11 | "Process arguments should now be a list to keep the ordering " 12 | "of the arguments. Dictionary arguments are deprecated for " 13 | "process activities." 14 | ) 15 | DeprecatedVaultMissingPathMessage = ( 16 | "Vault secrets must now specify the `path` property. The `key` property " 17 | "is now a key of a Vault secret rather than the actual path. For " 18 | "instance: " 19 | "{'my-key': {'type': 'vault', 'key': 'foo'}} " 20 | "now becomes: " 21 | "{'my-key': {'type': 'vault', 'path': 'foo'}}" 22 | ) 23 | 24 | 25 | def warn_about_deprecated_features(experiment: Experiment): 26 | """ 27 | Warn about deprecated features. 28 | 29 | We do it globally so that we can warn only once about each feature and 30 | avoid repeating the same message over and over again. 31 | """ 32 | warned_deprecations = { 33 | DeprecatedDictArgsMessage: False, 34 | DeprecatedVaultMissingPathMessage: False, 35 | } 36 | activities = get_all_activities_in_experiment(experiment) 37 | 38 | for activity in activities: 39 | provider = activity.get("provider") 40 | if not provider: 41 | continue 42 | 43 | provider_type = provider.get("type") 44 | if provider_type == "process": 45 | arguments = provider.get("arguments") 46 | if not warned_deprecations[ 47 | DeprecatedDictArgsMessage 48 | ] and isinstance(arguments, dict): 49 | warned_deprecations[DeprecatedDictArgsMessage] = True 50 | warnings.warn(DeprecatedDictArgsMessage, DeprecationWarning) 51 | logger.warning(DeprecatedDictArgsMessage) 52 | 53 | # vault now expects the path property 54 | # see https://github.com/chaostoolkit/chaostoolkit-lib/issues/77 55 | for target, keys in experiment.get("secrets", {}).items(): 56 | for key, value in keys.items(): 57 | if isinstance(value, dict) and value.get("type") == "vault": 58 | if "key" in value and "path" not in value: 59 | warned_deprecations[DeprecatedVaultMissingPathMessage] = ( 60 | True 61 | ) 62 | warnings.warn( 63 | DeprecatedVaultMissingPathMessage, DeprecationWarning 64 | ) 65 | logger.warning(DeprecatedVaultMissingPathMessage) 66 | 67 | 68 | def warn_about_moved_function(message: str): 69 | warnings.warn(message, DeprecationWarning, stacklevel=2) 70 | -------------------------------------------------------------------------------- /chaoslib/discovery/__init__.py: -------------------------------------------------------------------------------- 1 | from chaoslib.discovery.discover import ( 2 | discover, 3 | discover_actions, 4 | discover_probes, 5 | initialize_discovery_result, 6 | ) 7 | 8 | __all__ = [ 9 | "discover", 10 | "discover_actions", 11 | "discover_probes", 12 | "initialize_discovery_result", 13 | ] 14 | -------------------------------------------------------------------------------- /chaoslib/discovery/discover.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import logging 4 | import platform 5 | import uuid 6 | from datetime import datetime, timezone 7 | from typing import Any 8 | 9 | from chaoslib import __version__ 10 | from chaoslib.discovery.package import ( 11 | get_discover_function, 12 | install, 13 | load_package, 14 | ) 15 | from chaoslib.exceptions import DiscoveryFailed 16 | from chaoslib.types import DiscoveredActivities, Discovery 17 | 18 | __all__ = [ 19 | "discover", 20 | "discover_activities", 21 | "discover_actions", 22 | "discover_probes", 23 | "initialize_discovery_result", 24 | "portable_type_name", 25 | "portable_type_name_to_python_type", 26 | ] 27 | logger = logging.getLogger("chaostoolkit") 28 | 29 | 30 | def discover( 31 | package_name: str, 32 | discover_system: bool = True, 33 | download_and_install: bool = True, 34 | keep_activities_arguments: bool = True, 35 | ) -> Discovery: 36 | """ 37 | Discover the capabilities of an extension as well as the system it targets. 38 | 39 | Then apply any post discovery hook that are declared in the chaostoolkit 40 | settings under the `discovery/post-hook` section. 41 | 42 | By default, returns the arguments for each activity discovered unless 43 | `keep_activities_arguments` is set to `False`. 44 | """ 45 | if download_and_install: 46 | install(package_name) 47 | package = load_package(package_name) 48 | discover_func = get_discover_function(package) 49 | 50 | discovery = discover_func(discover_system=discover_system) 51 | 52 | if not keep_activities_arguments: 53 | for activity in discovery["activities"]: 54 | activity.pop("arguments", None) 55 | activity.pop("return_type", None) 56 | 57 | return discovery 58 | 59 | 60 | def initialize_discovery_result( 61 | extension_name: str, extension_version: str, discovery_type: str 62 | ) -> Discovery: 63 | """ 64 | Intialize the discovery result payload to fill with activities and system 65 | discovery. 66 | """ 67 | plt = platform.uname() 68 | return { 69 | "chaoslib_version": __version__, 70 | "id": str(uuid.uuid4()), 71 | "target": discovery_type, 72 | "date": datetime.now(timezone.utc).isoformat(), 73 | "platform": { 74 | "system": plt.system, 75 | "node": plt.node, 76 | "release": plt.release, 77 | "version": plt.version, 78 | "machine": plt.machine, 79 | "proc": plt.processor, 80 | "python": platform.python_version(), 81 | }, 82 | "extension": { 83 | "name": extension_name, 84 | "version": extension_version, 85 | }, 86 | "activities": [], 87 | "system": None, 88 | } 89 | 90 | 91 | def discover_actions(extension_mod_name: str) -> DiscoveredActivities: 92 | """ 93 | Discover actions from the given extension named `extension_mod_name`. 94 | """ 95 | logger.debug(f"Searching for actions in {extension_mod_name}") 96 | return discover_activities(extension_mod_name, "action") 97 | 98 | 99 | def discover_probes(extension_mod_name: str) -> DiscoveredActivities: 100 | """ 101 | Discover probes from the given extension named `extension_mod_name`. 102 | """ 103 | logger.debug(f"Searching for probes in {extension_mod_name}") 104 | return discover_activities(extension_mod_name, "probe") 105 | 106 | 107 | def discover_activities( 108 | extension_mod_name: str, 109 | activity_type: str, # noqa: C901 110 | ) -> DiscoveredActivities: 111 | """ 112 | Discover exported activities from the given extension module name. 113 | """ 114 | try: 115 | mod = importlib.import_module(extension_mod_name) 116 | except ImportError: 117 | raise DiscoveryFailed( 118 | f"could not import extension module '{extension_mod_name}'" 119 | ) 120 | 121 | activities = [] 122 | try: 123 | exported = getattr(mod, "__all__") 124 | except AttributeError: 125 | logger.warning( 126 | "'{m}' does not expose the __all__ attribute. " 127 | "It is required to determine what functions are actually " 128 | "exported as activities.".format(m=extension_mod_name) 129 | ) 130 | return activities 131 | 132 | funcs = inspect.getmembers(mod, inspect.isfunction) 133 | for name, func in funcs: 134 | if exported and name not in exported: 135 | # do not return "private" functions 136 | continue 137 | 138 | sig = inspect.signature(func) 139 | activity = { 140 | "type": activity_type, 141 | "name": name, 142 | "mod": mod.__name__, 143 | "doc": inspect.getdoc(func), 144 | "arguments": [], 145 | } 146 | 147 | if sig.return_annotation is not inspect.Signature.empty: 148 | activity["return_type"] = portable_type_name(sig.return_annotation) 149 | 150 | for param in sig.parameters.values(): 151 | if param.kind in (param.KEYWORD_ONLY, param.VAR_KEYWORD): 152 | continue 153 | 154 | arg = { 155 | "name": param.name, 156 | } 157 | 158 | if param.default is not inspect.Parameter.empty: 159 | arg["default"] = param.default 160 | if param.annotation is not inspect.Parameter.empty: 161 | arg["type"] = portable_type_name(param.annotation) 162 | activity["arguments"].append(arg) 163 | 164 | activities.append(activity) 165 | 166 | return activities 167 | 168 | 169 | def portable_type_name(python_type: Any) -> str: # noqa: C901 170 | """ 171 | Return a fairly portable name for a Python type. The idea is to make it 172 | easy for consumer to read without caring for actual Python types 173 | themselves. 174 | 175 | These are not JSON types so don't eval them directly. This function does 176 | not try to be clever with rich types and will return `"object"` whenever 177 | it cannot make sense of the provide type. 178 | """ 179 | if python_type is None: 180 | return "null" 181 | elif python_type is bool: 182 | return "boolean" 183 | elif python_type is int: 184 | return "integer" 185 | elif python_type is float: 186 | return "number" 187 | elif python_type is str: 188 | return "string" 189 | elif python_type is bytes: 190 | return "byte" 191 | elif python_type is set: 192 | return "set" 193 | elif python_type is tuple: 194 | return "tuple" 195 | elif python_type is list: 196 | return "list" 197 | elif python_type is dict: 198 | return "mapping" 199 | elif str(python_type).startswith("typing.Dict"): 200 | return "mapping" 201 | elif str(python_type).startswith("typing.List"): 202 | return "list" 203 | elif str(python_type).startswith("typing.Set"): 204 | return "set" 205 | 206 | logger.debug( 207 | f"'{str(python_type)}' could not be ported to something meaningful" 208 | ) 209 | 210 | return "object" 211 | 212 | 213 | def portable_type_name_to_python_type(name: str) -> Any: # noqa: C901 214 | """ 215 | Return the Python type associated to the given portable name. 216 | """ 217 | if name == "null": 218 | return None 219 | elif name == "boolean": 220 | return bool 221 | elif name == "integer": 222 | return int 223 | elif name == "number": 224 | return float 225 | elif name == "string": 226 | return str 227 | elif name == "byte": 228 | return bytes 229 | elif name == "set": 230 | return set 231 | elif name == "list": 232 | return list 233 | elif name == "tuple": 234 | return tuple 235 | elif name == "mapping": 236 | return dict 237 | 238 | return object 239 | -------------------------------------------------------------------------------- /chaoslib/discovery/package.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import logging 4 | import subprocess 5 | 6 | try: 7 | import importlib.metadata as importlib_metadata 8 | except ImportError: 9 | import importlib_metadata 10 | 11 | from chaoslib.exceptions import DiscoveryFailed 12 | 13 | __all__ = ["get_discover_function", "install", "load_package"] 14 | logger = logging.getLogger("chaostoolkit") 15 | 16 | 17 | def install(package_name: str): 18 | """ 19 | Use pip to download and install the `package_name` to the current Python 20 | environment. Pip can detect it is already installed. 21 | """ 22 | logger.info(f"Attempting to download and install package '{package_name}'") 23 | 24 | process = subprocess.run( 25 | ["pip", "install", "-U", package_name], 26 | stdout=subprocess.PIPE, 27 | stderr=subprocess.PIPE, 28 | ) 29 | 30 | stdout = process.stdout.decode("utf-8") 31 | stderr = process.stderr.decode("utf-8") 32 | logger.debug(stdout) 33 | 34 | if process.returncode != 0: 35 | msg = f"failed to install `{package_name}`" 36 | logger.debug( 37 | msg 38 | + "\n=================\n{o}\n=================\n{e}\n".format( 39 | o=stdout, e=stderr 40 | ) 41 | ) 42 | raise DiscoveryFailed(msg) 43 | 44 | logger.info("Package downloaded and installed in current environment") 45 | 46 | 47 | def load_package(package_name: str) -> object: 48 | """ 49 | Import the module into the current process state. 50 | """ 51 | name = get_importname_from_package(package_name) 52 | try: 53 | package = importlib.import_module(name) 54 | except ImportError: 55 | raise DiscoveryFailed(f"could not load Python module '{name}'") 56 | 57 | return package 58 | 59 | 60 | def get_discover_function(package: object): 61 | """ 62 | Lookup the `discover` function from the given imported package. 63 | """ 64 | funcs = inspect.getmembers(package, inspect.isfunction) 65 | for name, value in funcs: 66 | if name == "discover": 67 | return value 68 | 69 | raise DiscoveryFailed( 70 | "package '{name}' does not export a `discover` function".format( 71 | name=package.__name__ 72 | ) 73 | ) 74 | 75 | 76 | ############################################################################### 77 | # Private functions 78 | ############################################################################### 79 | def get_importname_from_package(package_name: str) -> str: 80 | """ 81 | Try to fetch the name of the top-level import name for the given 82 | package. For some reason, this isn't straightforward. 83 | 84 | For now, we do not support distribution packages that contains 85 | multiple top-level packages. 86 | """ 87 | # do we even have a distribution matching the one requested? 88 | try: 89 | importlib_metadata.distribution(package_name) 90 | except importlib_metadata.PackageNotFoundError: 91 | raise DiscoveryFailed(f"Package {package_name} not found") 92 | 93 | # we have a distribution, now we still need to iterate over all 94 | # distributions to hopefully locate the top-level package name 95 | packages = importlib_metadata.packages_distributions() 96 | for name in packages: 97 | if package_name in packages[name]: 98 | return name 99 | 100 | raise DiscoveryFailed( 101 | f"Distribution {package_name} exists but no top-level package name " 102 | "could be determined" 103 | ) 104 | -------------------------------------------------------------------------------- /chaoslib/exceptions.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "ChaosException", 3 | "InvalidExperiment", 4 | "InvalidActivity", 5 | "ActivityFailed", 6 | "DiscoveryFailed", 7 | "InvalidSource", 8 | "InterruptExecution", 9 | "ControlPythonFunctionLoadingError", 10 | "InvalidControl", 11 | ] 12 | 13 | 14 | class ChaosException(Exception): 15 | pass 16 | 17 | 18 | class InvalidActivity(ChaosException): 19 | pass 20 | 21 | 22 | class InvalidExperiment(ChaosException): 23 | pass 24 | 25 | 26 | class ActivityFailed(ChaosException): 27 | pass 28 | 29 | 30 | # please use ActivityFailed rather than the old name for this exception 31 | FailedActivity = ActivityFailed 32 | 33 | 34 | class DiscoveryFailed(ChaosException): 35 | pass 36 | 37 | 38 | class InvalidSource(ChaosException): 39 | pass 40 | 41 | 42 | class ControlPythonFunctionLoadingError(Exception): 43 | pass 44 | 45 | 46 | class InterruptExecution(ChaosException): 47 | pass 48 | 49 | 50 | class InvalidControl(ChaosException): 51 | pass 52 | 53 | 54 | class ExperimentExitedException(ChaosException): 55 | """ 56 | Only raised when the process received a SIGUSR2 signal. 57 | 58 | Raised into the blocking background activities of the method only. 59 | 60 | If you catch it, this mean you can clean your activity but you should 61 | really raise another exception again to let the Chaos Toolkit 62 | quickly terminate. 63 | """ 64 | 65 | pass 66 | -------------------------------------------------------------------------------- /chaoslib/exit.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is advanced usage and mostly interesting to users who need to 3 | be able to terminate an experiment's execution as fast as possible and, 4 | potentially, without much care for cleaning up afterwards. 5 | 6 | If you want to interrup an execution but can affort to wait for graceful 7 | completion (current activty, rollbacks...) it's probably best to rely on 8 | the control interface. 9 | 10 | If you need this utility here, you can simply do as follows: 11 | 12 | 13 | ```python 14 | from chaoslib.exit import exit_ungracefully 15 | 16 | def my_probe(): 17 | # whatever condition comes up that shows you need to terminate asap 18 | exit_ungracefully() 19 | ``` 20 | 21 | Then in your experiment 22 | 23 | ```json 24 | "method": [ 25 | { 26 | "type": "probe", 27 | "name": "interrupt-when-system-is-unhappy", 28 | "background": True, 29 | "provider": { 30 | "type": "python", 31 | "module": "mymod", 32 | "func": "my_probe" 33 | } 34 | } 35 | ... 36 | ] 37 | ``` 38 | 39 | This will start your probe in the background. 40 | 41 | WARNING: Only available on Unix/Linux systems. 42 | """ 43 | 44 | import inspect 45 | import logging 46 | import os 47 | import platform 48 | import signal 49 | from contextlib import contextmanager 50 | from types import FrameType 51 | 52 | from chaoslib.exceptions import InterruptExecution 53 | 54 | __all__ = ["exit_gracefully", "exit_ungracefully", "exit_signals"] 55 | 56 | logger = logging.getLogger("chaostoolkit") 57 | 58 | 59 | @contextmanager 60 | def exit_signals(): 61 | """ 62 | Register the handlers for SIGTERM, SIGUSR1 and SIGUSR2 signals. 63 | Puts back the original handlers when the call ends. 64 | 65 | SIGTERM will trigger an InterruptExecution exception 66 | SIGUSR1 is used to terminate the experiment now while keeping the 67 | rollbacks if they were declared. 68 | SIGUSR2 is used to terminate the experiment without ever running the 69 | rollbacks. 70 | 71 | Generally speaking using signals this way is a bit of an overkill but 72 | the Python VM has no other mechanism to interrupt blocking calls. 73 | 74 | WARNING: SIGUSR1 and SIGUSR2 are only available on Unix/Linux systems. 75 | """ 76 | sigterm_handler = signal.signal(signal.SIGTERM, _terminate_now) 77 | 78 | if hasattr(signal, "SIGUSR1") and hasattr(signal, "SIGUSR2"): 79 | # keep a reference to the original handlers 80 | sigusr1_handler = signal.signal(signal.SIGUSR1, _leave_now) 81 | sigusr2_handler = signal.signal(signal.SIGUSR2, _leave_now) 82 | try: 83 | yield 84 | finally: 85 | signal.signal(signal.SIGTERM, sigterm_handler) 86 | signal.signal(signal.SIGUSR1, sigusr1_handler) 87 | signal.signal(signal.SIGUSR2, sigusr2_handler) 88 | else: 89 | # On a system that doesn't support SIGUSR signals 90 | # not much we can do... 91 | logger.debug( 92 | f"System '{platform.platform()}' does not expose SIGUSR signals" 93 | ) 94 | try: 95 | yield 96 | finally: 97 | signal.signal(signal.SIGTERM, sigterm_handler) 98 | 99 | 100 | def exit_gracefully(): 101 | """ 102 | Sends a user signal to the chaostoolkit process which should terminate 103 | the current execution immediatly, but gracefully. 104 | 105 | WARNING: Only available on Unix/Linux systems. 106 | """ 107 | if not hasattr(signal, "SIGUSR1"): 108 | frames = inspect.getouterframes(inspect.currentframe()) 109 | info = frames[1] 110 | logger.error( 111 | "Cannot call 'chaoslib.exit.exit_ungracefully() [{} - line {}] " 112 | "as it relies on the SIGUSR1 signal which is not available on " 113 | "your platform".format(info.filename, info.lineno) 114 | ) 115 | return 116 | 117 | os.kill(os.getpid(), signal.SIGUSR1) 118 | 119 | 120 | def exit_ungracefully(): 121 | """ 122 | Sends a user signal to the chaostoolkit process which should terminate 123 | the current execution immediatly, but not gracefully. 124 | 125 | This means the rollbacks will not be executed, although controls 126 | will be correctly terminated. 127 | 128 | WARNING: Only available on Unix/Linux systems. 129 | """ 130 | if not hasattr(signal, "SIGUSR2"): 131 | frames = inspect.getouterframes(inspect.currentframe()) 132 | info = frames[1] 133 | logger.error( 134 | "Cannot call 'chaoslib.exit.exit_ungracefully() [{} - line {}] " 135 | "as it relies on the SIGUSR2 signal which is not available on " 136 | "your platform".format(info.filename, info.lineno) 137 | ) 138 | return 139 | 140 | os.kill(os.getpid(), signal.SIGUSR2) 141 | 142 | 143 | ############################################################################### 144 | # Internals 145 | ############################################################################### 146 | def _leave_now(signum: int, frame: FrameType = None) -> None: 147 | """ 148 | Signal handler only interested in SIGUSR1 and SIGUSR2 to indicate 149 | requested termination of the experiment. 150 | """ 151 | if signum == signal.SIGUSR1: 152 | raise SystemExit(20) 153 | 154 | elif signum == signal.SIGUSR2: 155 | raise SystemExit(30) 156 | 157 | 158 | def _terminate_now(signum: int, frame: FrameType = None) -> None: 159 | """ 160 | Signal handler for the SIGTERM event. Raises an `InterruptExecution`. 161 | """ 162 | if signum == signal.SIGTERM: 163 | logger.warning("Caught SIGTERM signal, interrupting experiment now") 164 | raise InterruptExecution("SIGTERM signal received") 165 | -------------------------------------------------------------------------------- /chaoslib/experiment.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from concurrent.futures import ThreadPoolExecutor 3 | from typing import Any, Dict, List 4 | 5 | from chaoslib.activity import ensure_activity_is_valid 6 | from chaoslib.caching import lookup_activity, with_cache 7 | from chaoslib.configuration import load_configuration 8 | from chaoslib.control import validate_controls 9 | from chaoslib.deprecation import ( 10 | warn_about_deprecated_features, 11 | warn_about_moved_function, 12 | ) 13 | from chaoslib.exceptions import InvalidActivity, InvalidExperiment 14 | from chaoslib.extension import validate_extensions 15 | from chaoslib.hypothesis import ensure_hypothesis_is_valid 16 | from chaoslib.loader import load_experiment 17 | from chaoslib.run import RunEventHandler, Runner 18 | from chaoslib.run import apply_activities as apply_act 19 | from chaoslib.run import apply_rollbacks as apply_roll 20 | from chaoslib.run import initialize_run_journal as init_journal 21 | from chaoslib.secret import load_secrets 22 | from chaoslib.types import ( 23 | Configuration, 24 | Dry, 25 | Experiment, 26 | Journal, 27 | Run, 28 | Schedule, 29 | Secrets, 30 | Settings, 31 | Strategy, 32 | ) 33 | 34 | __all__ = ["ensure_experiment_is_valid", "load_experiment"] 35 | 36 | logger = logging.getLogger("chaostoolkit") 37 | 38 | 39 | @with_cache 40 | def ensure_experiment_is_valid(experiment: Experiment): 41 | """ 42 | A chaos experiment consists of a method made of activities to carry 43 | sequentially. 44 | 45 | There are two kinds of activities: 46 | 47 | * probe: detecting the state of a resource in your system or external to it 48 | There are two kinds of probes: `steady` and `close` 49 | * action: an operation to apply against your system 50 | 51 | Usually, an experiment is made of a set of `steady` probes that ensure the 52 | system is sound to carry further the experiment. Then, an action before 53 | another set of of ̀close` probes to sense the state of the system 54 | post-action. 55 | 56 | This function raises :exc:`InvalidExperiment`, :exc:`InvalidProbe` or 57 | :exc:`InvalidAction` depending on where it fails. 58 | """ 59 | logger.info("Validating the experiment's syntax") 60 | 61 | if not experiment: 62 | raise InvalidExperiment("an empty experiment is not an experiment") 63 | 64 | if not experiment.get("title"): 65 | raise InvalidExperiment("experiment requires a title") 66 | 67 | if not experiment.get("description"): 68 | raise InvalidExperiment("experiment requires a description") 69 | 70 | tags = experiment.get("tags") 71 | if tags: 72 | if list(filter(lambda t: t == "" or not isinstance(t, str), tags)): 73 | raise InvalidExperiment( 74 | "experiment tags must be a non-empty string" 75 | ) 76 | 77 | validate_extensions(experiment) 78 | 79 | config = load_configuration(experiment.get("configuration", {})) 80 | load_secrets(experiment.get("secrets", {}), config) 81 | 82 | ensure_hypothesis_is_valid(experiment) 83 | 84 | method = experiment.get("method") 85 | if method is None: 86 | # we force the method key to be indicated, to make it clear 87 | # that the SSH will still be executed before & after the method block 88 | raise InvalidExperiment( 89 | "an experiment requires a method, " 90 | "which can be empty for only checking steady state hypothesis " 91 | ) 92 | 93 | for activity in method: 94 | ensure_activity_is_valid(activity) 95 | 96 | # let's see if a ref is indeed found in the experiment 97 | ref = activity.get("ref") 98 | if ref and not lookup_activity(ref): 99 | raise InvalidActivity( 100 | "referenced activity '{r}' could not be " 101 | "found in the experiment".format(r=ref) 102 | ) 103 | 104 | rollbacks = experiment.get("rollbacks", []) 105 | for activity in rollbacks: 106 | ensure_activity_is_valid(activity) 107 | 108 | warn_about_deprecated_features(experiment) 109 | 110 | validate_controls(experiment) 111 | 112 | logger.info("Experiment looks valid") 113 | 114 | 115 | @with_cache 116 | def run_experiment( 117 | experiment: Experiment, 118 | settings: Settings = None, 119 | experiment_vars: Dict[str, Any] = None, 120 | strategy: Strategy = Strategy.DEFAULT, 121 | schedule: Schedule = None, 122 | event_handlers: List[RunEventHandler] = None, 123 | ) -> Journal: 124 | """ 125 | Run the given `experiment` method step by step, in the following sequence: 126 | steady probe, action, close probe. 127 | 128 | Activities can be executed in background when they have the 129 | `"background"` property set to `true`. In that case, the activity is run in 130 | a thread. By the end of runs, those threads block until they are all 131 | complete. 132 | 133 | If the experiment has the `"dry"` property set to `activities`,the experiment 134 | runs without actually executing the activities. 135 | 136 | NOTE: Tricky to make a decision whether we should rollback when exiting 137 | abnormally (Ctrl-C, SIGTERM...). Afterall, there is a chance we actually 138 | cannot afford to rollback properly. Better bailing to a conservative 139 | approach. This means we swallow :exc:`KeyboardInterrupt` and 140 | :exc:`SystemExit` and do not bubble it back up to the caller. We when were 141 | interrupted, we set the `interrupted` flag of the result accordingly to 142 | notify the caller this was indeed not terminated properly. 143 | """ 144 | with Runner(strategy, schedule) as runner: 145 | if event_handlers: 146 | for h in event_handlers: 147 | runner.register_event_handler(h) 148 | return runner.run(experiment, settings, experiment_vars=experiment_vars) 149 | 150 | 151 | def initialize_run_journal(experiment: Experiment) -> Journal: 152 | warn_about_moved_function( 153 | "The 'initialize_run_journal' function has now moved to the " 154 | "'chaoslib.run' package" 155 | ) 156 | return init_journal(experiment) 157 | 158 | 159 | def apply_activities( 160 | experiment: Experiment, 161 | configuration: Configuration, 162 | secrets: Secrets, 163 | pool: ThreadPoolExecutor, 164 | journal: Journal, 165 | dry: Dry, 166 | ) -> List[Run]: 167 | warn_about_moved_function( 168 | "The 'apply_activities' function has now moved to the " 169 | "'chaoslib.run' package" 170 | ) 171 | return apply_act(experiment, configuration, secrets, pool, journal, dry) 172 | 173 | 174 | def apply_rollbacks( 175 | experiment: Experiment, 176 | configuration: Configuration, 177 | secrets: Secrets, 178 | pool: ThreadPoolExecutor, 179 | dry: Dry, 180 | ) -> List[Run]: 181 | warn_about_moved_function( 182 | "The 'apply_rollbacks' function has now moved to the " 183 | "'chaoslib.run' package" 184 | ) 185 | return apply_roll(experiment, configuration, secrets, pool, dry) 186 | -------------------------------------------------------------------------------- /chaoslib/extension.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from chaoslib.exceptions import InvalidExperiment 4 | from chaoslib.types import Experiment, Extension 5 | 6 | __all__ = [ 7 | "get_extension", 8 | "has_extension", 9 | "set_extension", 10 | "merge_extension", 11 | "remove_extension", 12 | "validate_extensions", 13 | ] 14 | 15 | 16 | def validate_extensions(experiment: Experiment): 17 | """ 18 | Validate that extensions respect the specification. 19 | """ 20 | extensions = experiment.get("extensions") 21 | if not extensions: 22 | return 23 | 24 | for ext in extensions: 25 | ext_name = ext.get("name") 26 | if not ext_name or not ext_name.strip(): 27 | raise InvalidExperiment("All extensions require a non-empty name") 28 | 29 | 30 | def get_extension(experiment: Experiment, name: str) -> Optional[Extension]: 31 | """ 32 | Get an extension by its name. 33 | 34 | If no extensions were defined, or the extension doesn't exist in this 35 | experiment, return `None`. 36 | """ 37 | extensions = experiment.get("extensions") 38 | if not extensions: 39 | return None 40 | 41 | for ext in extensions: 42 | ext_name = ext.get("name") 43 | if ext_name == name: 44 | return ext 45 | 46 | return None 47 | 48 | 49 | def has_extension(experiment: Experiment, name: str) -> bool: 50 | """ 51 | Check if an extension is declared in this experiment. 52 | """ 53 | return get_extension(experiment, name) is not None 54 | 55 | 56 | def set_extension(experiment: Experiment, extension: Extension): 57 | """ 58 | Set an extension in this experiment. 59 | 60 | If the extension already exists, it is overriden by the new one. 61 | """ 62 | if "extensions" not in experiment: 63 | experiment["extensions"] = [] 64 | 65 | name = extension.get("name") 66 | for ext in experiment["extensions"]: 67 | ext_name = ext.get("name") 68 | if ext_name == name: 69 | experiment["extensions"].remove(ext) 70 | break 71 | experiment["extensions"].append(extension) 72 | 73 | 74 | def remove_extension(experiment: Experiment, name: str): 75 | """ 76 | Remove an extension from this experiment. 77 | """ 78 | if "extensions" not in experiment: 79 | return None 80 | 81 | for ext in experiment["extensions"]: 82 | ext_name = ext.get("name") 83 | if ext_name == name: 84 | experiment["extensions"].remove(ext) 85 | break 86 | 87 | 88 | def merge_extension(experiment: Experiment, extension: Extension): 89 | """ 90 | Merge an extension in this experiment. 91 | 92 | If the extension does not exist yet, it is added. The merge is at the 93 | first level only. 94 | """ 95 | if "extensions" not in experiment: 96 | experiment["extensions"] = [] 97 | 98 | name = extension.get("name") 99 | for ext in experiment["extensions"]: 100 | ext_name = ext.get("name") 101 | if ext_name == name: 102 | ext.update(extension) 103 | break 104 | else: 105 | experiment["extensions"].append(extension) 106 | -------------------------------------------------------------------------------- /chaoslib/info.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from typing import List 3 | 4 | try: 5 | import importlib.metadata as importlib_metadata 6 | except ImportError: 7 | import importlib_metadata 8 | 9 | __all__ = ["list_extensions"] 10 | 11 | 12 | info_fields = ["name", "version", "summary", "license", "author", "url"] 13 | 14 | 15 | class ExtensionInfo(namedtuple("ExtensionInfo", info_fields)): 16 | __slots__ = () 17 | 18 | 19 | def list_extensions() -> List[ExtensionInfo]: 20 | """ 21 | List all installed Chaos Toolkit extensions in the current environment. 22 | 23 | Notice, for now we can only list extensions that start with `chaostoolkit-` 24 | in their package name. 25 | 26 | This is not as powerful and solid as we want it to be. The trick is that we 27 | can't rely on any metadata inside extensions to tell us they exist and 28 | what functionnality they provide either. Python has the concept of trove 29 | classifiers on packages but we can't extend them yet so they are of no use 30 | to us. 31 | 32 | In a future version, we will provide a mechanism from packages to support 33 | a better detection. 34 | """ 35 | infos = [] 36 | distros = importlib_metadata.distributions() 37 | seen = [] 38 | for dist in distros: 39 | info = dist.metadata 40 | name = info["Name"] 41 | if name == "chaostoolkit-lib": 42 | continue 43 | if name in seen: 44 | continue 45 | seen.append(name) 46 | if name.startswith("chaostoolkit-"): 47 | ext = ExtensionInfo( 48 | name=name, 49 | version=info["Version"], 50 | summary=info["Summary"], 51 | license=info["License"], 52 | author=info["Author"], 53 | url=info["Home-page"], 54 | ) 55 | infos.append(ext) 56 | return infos 57 | -------------------------------------------------------------------------------- /chaoslib/loader.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os.path 4 | from json.decoder import JSONDecodeError 5 | from urllib.parse import urlparse 6 | 7 | import requests 8 | import yaml 9 | 10 | from chaoslib.control import controls 11 | from chaoslib.exceptions import InvalidExperiment, InvalidSource 12 | from chaoslib.types import Experiment, Settings 13 | 14 | __all__ = ["load_experiment"] 15 | 16 | logger = logging.getLogger("chaostoolkit") 17 | 18 | 19 | def parse_experiment_from_file(path: str) -> Experiment: 20 | """ 21 | Parse the given experiment from `path` and return it. 22 | """ 23 | with open(path) as f: 24 | p, ext = os.path.splitext(path) 25 | if ext in (".yaml", ".yml"): 26 | try: 27 | return yaml.safe_load(f) 28 | except yaml.YAMLError as ye: 29 | raise InvalidSource( 30 | f"Failed parsing YAML experiment: {str(ye)}" 31 | ) 32 | elif ext == ".json": 33 | return json.load(f) 34 | 35 | raise InvalidExperiment( 36 | "only files with json, yaml or yml extensions are supported" 37 | ) 38 | 39 | 40 | def parse_experiment_from_http(response: requests.Response) -> Experiment: 41 | """ 42 | Parse the given experiment from the request's `response`. 43 | """ 44 | content_type = response.headers.get("Content-Type") 45 | 46 | if "application/json" in content_type: 47 | return response.json() 48 | elif "application/x-yaml" in content_type or "text/yaml" in content_type: 49 | try: 50 | return yaml.safe_load(response.text) 51 | except yaml.YAMLError as ye: 52 | raise InvalidSource(f"Failed parsing YAML experiment: {str(ye)}") 53 | elif "text/plain" in content_type: 54 | content = response.text 55 | try: 56 | return json.loads(content) 57 | except JSONDecodeError: 58 | try: 59 | return yaml.safe_load(content) 60 | except yaml.YAMLError: 61 | pass 62 | 63 | raise InvalidExperiment( 64 | "only files with json, yaml or yml extensions are supported" 65 | ) 66 | 67 | 68 | def load_experiment( 69 | experiment_source: str, settings: Settings = None, verify_tls: bool = True 70 | ) -> Experiment: 71 | """ 72 | Load an experiment from the given source. 73 | 74 | The source may be a local file or a HTTP(s) URL. If the endpoint requires 75 | authentication, please set the appropriate entry in the settings file, 76 | under the `auths:` section, keyed by domain. For instance: 77 | 78 | ```yaml 79 | auths: 80 | mydomain.com: 81 | type: basic 82 | value: XYZ 83 | otherdomain.com: 84 | type: bearer 85 | value: UIY 86 | localhost:8081: 87 | type: digest 88 | value: UIY 89 | ``` 90 | 91 | Set `verify_tls` to `False` if the source is a over a self-signed 92 | certificate HTTP endpoint to instruct the loader to not verify the 93 | certificates. 94 | """ 95 | with controls(level="loader", context=experiment_source) as control: 96 | if os.path.exists(experiment_source): 97 | parsed = parse_experiment_from_file(experiment_source) 98 | control.with_state(parsed) 99 | return parsed 100 | 101 | p = urlparse(experiment_source) 102 | if not p.scheme and not os.path.exists(p.path): 103 | raise InvalidSource(f'Path "{p.path}" does not exist.') 104 | 105 | if p.scheme not in ("http", "https"): 106 | raise InvalidSource( 107 | f"'{p.scheme}' is not a supported source scheme." 108 | ) 109 | 110 | ctk_bearer_token = os.getenv("CHAOSTOOLKIT_LOADER_AUTH_BEARER_TOKEN") 111 | headers = {"Accept": "application/json, application/x-yaml"} 112 | if ctk_bearer_token: 113 | headers["Authorization"] = "bearer {}".format( 114 | ctk_bearer_token.strip() 115 | ) 116 | elif settings: 117 | auths = settings.get("auths", []) 118 | for domain in auths: 119 | if domain == p.netloc: 120 | auth = auths[domain] 121 | headers["Authorization"] = "{} {}".format( 122 | auth["type"], auth["value"] 123 | ) 124 | break 125 | 126 | r = requests.get(experiment_source, headers=headers, verify=verify_tls) 127 | if r.status_code != 200: 128 | raise InvalidSource(f"Failed to fetch the experiment: {r.text}") 129 | 130 | logger.debug(f"Fetched experiment: \n{r.text}") 131 | parsed = parse_experiment_from_http(r) 132 | control.with_state(parsed) 133 | return parsed 134 | -------------------------------------------------------------------------------- /chaoslib/log.py: -------------------------------------------------------------------------------- 1 | try: 2 | import curses # type: ignore 3 | except ImportError: 4 | curses = None 5 | 6 | import decimal 7 | import logging 8 | import os 9 | import sys 10 | import uuid 11 | from datetime import date, datetime 12 | from logging.handlers import RotatingFileHandler 13 | from types import ModuleType 14 | from typing import Dict 15 | 16 | from pythonjsonlogger import jsonlogger 17 | 18 | 19 | if os.name == "nt": 20 | from colorama import init as colorama_init 21 | 22 | colorama_init() 23 | 24 | 25 | __all__ = ["configure_logger"] 26 | 27 | 28 | def encoder(o: object) -> str: 29 | """ 30 | Perform some additional encoding for types JSON doesn't support natively. 31 | 32 | We don't try to respect any ECMA specification here as we want to retain 33 | as much information as we can. 34 | """ 35 | if isinstance(o, (date, datetime)): 36 | # we do not meddle with the timezone and assume the date was 37 | # stored with the right information of timezone as +-HH:MM 38 | return o.isoformat() 39 | elif isinstance(o, decimal.Decimal): 40 | return str(o) 41 | elif isinstance(o, uuid.UUID): 42 | return str(o) 43 | 44 | raise TypeError(f"Object of type '{type(o)}' is not JSON serializable") 45 | 46 | 47 | def configure_logger( 48 | verbose: bool = False, 49 | log_format: str = "string", 50 | log_file: str = None, 51 | log_file_level: str = "debug", 52 | logger_name: str = "chaostoolkit", 53 | context_id: str = None, 54 | override_logzero_if_present: bool = True, 55 | ): 56 | """ 57 | Configure the chaostoolkit logger. 58 | 59 | By default logs as strings to stdout and the given file. When `log_format` 60 | is `"json"`, records are set to the console as JSON strings but remain 61 | as strings in the log file. The rationale is that the log file is mostly 62 | for grepping purpose while records written to the console can be forwarded 63 | out of band to anywhere else. 64 | 65 | When `override_logzero_if_present` is set, we replace 66 | `sys.modules["logzero"]` with a fake module so that logzero is disabled. 67 | """ 68 | if override_logzero_if_present: 69 | override_logzero_module() 70 | 71 | log_level = logging.INFO 72 | 73 | # we define colors ourselves as critical is missing in default ones 74 | colors = { 75 | logging.DEBUG: "\033[36m", 76 | logging.INFO: "\033[32m", 77 | logging.WARNING: "\033[33m", 78 | logging.ERROR: "\033[31m", 79 | logging.CRITICAL: "\033[31m", 80 | } 81 | fmt = "%(color)s[%(asctime)s %(levelname)s]%(end_color)s %(message)s" 82 | if verbose: 83 | log_level = logging.DEBUG 84 | fmt = ( 85 | "%(color)s[%(asctime)s %(levelname)s] " 86 | "[%(module)s:%(lineno)d]%(end_color)s %(message)s" 87 | ) 88 | 89 | formatter = LogFormatter( 90 | fmt=fmt, datefmt="%Y-%m-%d %H:%M:%S", colors=colors 91 | ) 92 | if log_format == "json": 93 | if sys.version_info < (3, 8): 94 | fmt = "(process) (asctime) (levelname) (module) (lineno) (message)" 95 | else: 96 | fmt = "%(process) %(asctime) %(levelname) %(module) %(lineno) %(message)" 97 | if context_id: 98 | fmt = f"(context_id) {fmt}" 99 | formatter = jsonlogger.JsonFormatter( 100 | fmt, json_default=encoder, timestamp=True 101 | ) 102 | 103 | logger = logging.getLogger(logger_name) 104 | logger.propagate = False 105 | logger.setLevel(log_level) 106 | 107 | handler = logging.StreamHandler() 108 | handler.setLevel(log_level) 109 | handler.setFormatter(formatter) 110 | logger.addHandler(handler) 111 | 112 | if context_id: 113 | logger.addFilter(ChaosToolkitContextFilter(logger_name, context_id)) 114 | 115 | if log_file: 116 | # always everything as strings in the log file 117 | logger.setLevel(logging.DEBUG) 118 | log_file_level = logging.getLevelName(log_file_level.upper()) 119 | fmt = ( 120 | "%(color)s[%(asctime)s %(levelname)s] " 121 | "[%(module)s:%(lineno)d]%(end_color)s %(message)s" 122 | ) 123 | formatter = LogFormatter( 124 | fmt=fmt, datefmt="%Y-%m-%d %H:%M:%S", colors=colors 125 | ) 126 | handler = RotatingFileHandler(log_file) 127 | handler.setLevel(log_file_level) 128 | handler.setFormatter(formatter) 129 | logger.addHandler(handler) 130 | 131 | 132 | ############################################################################### 133 | # Private function 134 | ############################################################################### 135 | def override_logzero_module() -> None: 136 | """ 137 | To remove our dependency on logzero, we need to make sure we 138 | take its place in the import path. 139 | 140 | Call this as early as possible and of cousre before importing logzero 141 | """ 142 | m = ModuleType("logzero") 143 | m.__path__ = [] 144 | sys.modules[m.__name__] = m 145 | m.logger = logging.getLogger("chaostoolkit") 146 | 147 | 148 | class ChaosToolkitContextFilter(logging.Filter): 149 | def __init__(self, name: str = "", context_id: str = None): 150 | logging.Filter.__init__(self, name) 151 | self.context_id = context_id or str(uuid.uuid4()) 152 | 153 | def filter(self, record: logging.LogRecord) -> bool: 154 | record.context_id = self.context_id 155 | return True 156 | 157 | 158 | class LogFormatter(logging.Formatter): 159 | # adjusted from logzero 160 | def __init__(self, fmt: str, datefmt: str, colors: Dict[str, str]) -> None: 161 | logging.Formatter.__init__(self, datefmt=datefmt) 162 | 163 | self._fmt = fmt 164 | self._colors = colors 165 | self._normal = "" 166 | 167 | if colors and terminal_has_colors(): 168 | self._normal = "\033[39m" 169 | 170 | def format(self, record: logging.LogRecord) -> str: 171 | record.asctime = self.formatTime(record, self.datefmt) 172 | record.message = record.getMessage() 173 | 174 | if record.levelno in self._colors: 175 | record.color = self._colors[record.levelno] 176 | record.end_color = self._normal 177 | else: 178 | record.color = record.end_color = "" 179 | 180 | formatted = self._fmt % record.__dict__ 181 | 182 | if record.exc_info: 183 | if not record.exc_text: 184 | record.exc_text = self.formatException(record.exc_info) 185 | 186 | if record.exc_text: 187 | lines = [formatted.rstrip()] 188 | lines.extend(ln for ln in record.exc_text.split("\n")) 189 | formatted = "\n".join(lines) 190 | 191 | return formatted.replace("\n", "\n ") 192 | 193 | 194 | def terminal_has_colors() -> bool: 195 | # adjusted from logzero 196 | if os.name == "nt": 197 | return True 198 | 199 | if curses and hasattr(sys.stderr, "isatty") and sys.stderr.isatty(): 200 | try: 201 | curses.setupterm() 202 | if curses.tigetnum("colors") > 0: 203 | return True 204 | 205 | except Exception: 206 | pass 207 | 208 | return False 209 | -------------------------------------------------------------------------------- /chaoslib/notification.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import json 4 | import logging 5 | from datetime import datetime, timezone 6 | from enum import Enum 7 | from typing import Any, Dict 8 | 9 | import requests 10 | from requests.exceptions import HTTPError 11 | 12 | from chaoslib import PayloadEncoder 13 | from chaoslib.types import EventPayload, Settings 14 | 15 | __all__ = [ 16 | "DiscoverFlowEvent", 17 | "InitFlowEvent", 18 | "RunFlowEvent", 19 | "ValidateFlowEvent", 20 | "notify", 21 | ] 22 | 23 | logger = logging.getLogger("chaostoolkit") 24 | 25 | 26 | class FlowEvent(Enum): 27 | pass 28 | 29 | 30 | class DiscoverFlowEvent(FlowEvent): 31 | DiscoverStarted = "discovery-started" 32 | DiscoverFailed = "discovery-failed" 33 | DiscoverCompleted = "discovery-completed" 34 | 35 | 36 | class InitFlowEvent(FlowEvent): 37 | InitStarted = "init-started" 38 | InitFailed = "init-failed" 39 | InitCompleted = "init-completed" 40 | 41 | 42 | class RunFlowEvent(FlowEvent): 43 | RunStarted = "run-started" 44 | RunFailed = "run-failed" 45 | RunCompleted = "run-completed" 46 | RunDeviated = "run-deviated" 47 | 48 | 49 | class ValidateFlowEvent(FlowEvent): 50 | ValidateStarted = "validate-started" 51 | ValidateFailed = "validate-failed" 52 | ValidateCompleted = "validate-completed" 53 | 54 | 55 | def notify( 56 | settings: Settings, 57 | event: FlowEvent, 58 | payload: Any = None, # noqa: C901 59 | error: Any = None, 60 | ): 61 | """ 62 | Go through all the notification channels declared in the settings and 63 | call them one by one. Only call those matching the current event. 64 | 65 | As this function is blocking, make sure none of your channels take too 66 | long to run. 67 | 68 | Whenever an error happened in the notification, a debug message is logged 69 | into the chaostoolkit log for review but this should not impact the 70 | experiment itself. 71 | 72 | When no settings were provided, no notifications are sent. Equally, if the 73 | settings do not define a `notifications` entry. Here is an example of 74 | settings: 75 | 76 | ```yaml 77 | notifications: 78 | - 79 | type: plugin 80 | module: somepackage.somemodule 81 | events: 82 | - init-failed 83 | - run-failed 84 | - 85 | type: http 86 | url: http://example.com 87 | headers: 88 | Authorization: "Bearer token" 89 | - 90 | type: http 91 | url: https://private.com 92 | verify_tls: false 93 | forward_event_payload: false 94 | headers: 95 | Authorization: "Bearer token" 96 | events: 97 | - discovery-completed 98 | - run-failed 99 | ``` 100 | 101 | In this sample, the first channel will be the `notify` function of the 102 | `somepackage.somemopdule` Python module. The other two notifications will 103 | be sent over HTTP with the third one not forwarding the event payload 104 | itself (hence being a GET rather than a POST). 105 | 106 | Notice how the first and third channels take an `events` sequence. That 107 | list represents the events which those endpoints are interested in. In 108 | other words, they will only be called for those specific events. The second 109 | channel will be applied to all events. 110 | 111 | The payload event is a dictionary made of the following entries: 112 | 113 | - `"event"`: the event name 114 | - `"payload"`: the payload associated to this event (may be None) 115 | - `"phase"`: which phase this event was raised from 116 | - `"error"`: if an error was passed on to the function 117 | - `"ts"`: a UTC timestamp of when the event was raised 118 | """ 119 | if not settings: 120 | return 121 | 122 | notification_channels = settings.get("notifications") 123 | if not notification_channels: 124 | return 125 | 126 | event_payload = { 127 | "name": event.value, 128 | "payload": payload, 129 | "phase": "unknown", 130 | "ts": datetime.now(timezone.utc) 131 | .replace(tzinfo=timezone.utc) 132 | .timestamp(), 133 | } 134 | 135 | if error: 136 | event_payload["error"] = error 137 | 138 | event_class = event.__class__ 139 | if event_class is DiscoverFlowEvent: 140 | event_payload["phase"] = "discovery" 141 | elif event_class is InitFlowEvent: 142 | event_payload["phase"] = "init" 143 | elif event_class is RunFlowEvent: 144 | event_payload["phase"] = "run" 145 | elif event_class is ValidateFlowEvent: 146 | event_payload["phase"] = "validate" 147 | 148 | for channel in notification_channels: 149 | events = channel.get("events") 150 | if events and event.value not in events: 151 | continue 152 | 153 | channel_type = channel.get("type") 154 | if channel_type == "http": 155 | notify_with_http(channel, event_payload) 156 | elif channel_type == "plugin": 157 | notify_via_plugin(channel, event_payload) 158 | 159 | 160 | def notify_with_http(channel: Dict[str, str], payload: EventPayload): 161 | """ 162 | Call a notification endpoint over HTTP. 163 | 164 | The `channel` dictionary should contain at least the `url` of the endpoint. 165 | In addition, it may define extra `headers` and turn off TLS verification 166 | (useful against local endpoint with self-signed certificates). 167 | 168 | You may also set `forward_event_payload` to send a GET request instead of 169 | the default POST. In that case, the event payload will not be forwarded 170 | along. 171 | """ 172 | url = channel.get("url") 173 | headers = channel.get("headers") 174 | verify_tls = channel.get("verify_tls", True) 175 | forward_event_payload = channel.get("forward_event_payload", True) 176 | 177 | if url: 178 | try: 179 | if forward_event_payload: 180 | payload_encoded = json.loads( 181 | json.dumps(payload, cls=PayloadEncoder) 182 | ) 183 | 184 | resp = requests.post( 185 | url, 186 | headers=headers, 187 | verify=verify_tls, 188 | timeout=(2, 5), 189 | json=payload_encoded, 190 | ) 191 | else: 192 | resp = requests.get( 193 | url, headers=headers, verify=verify_tls, timeout=(2, 5) 194 | ) 195 | 196 | resp.raise_for_status() 197 | except HTTPError as ex: 198 | logger.debug(f"notification sent to {url} failed with: {ex}") 199 | except Exception as ex: 200 | logger.debug("failed calling notification endpoint", exc_info=ex) 201 | else: 202 | logger.debug("missing url in notification channel") 203 | 204 | 205 | def notify_via_plugin(channel: Dict[str, str], payload: EventPayload): 206 | """ 207 | Call a notification plugin as a Python function. 208 | 209 | The `channel` dictionary contains at least the `module` key of the package 210 | containing the function to be called. The function name defaults to 211 | `notify` but can be set via the `func` key of the dictionary. 212 | 213 | The function signature must take two positional arguments, a dict from the 214 | settings for that particular channel and the event payload. 215 | """ 216 | mod_name = channel.get("module") 217 | func_name = channel.get("func", "notify") 218 | 219 | try: 220 | mod = importlib.import_module(mod_name) 221 | except ImportError: 222 | logger.debug( 223 | "could not find Python plugin '{mod}' " "for notification".format( 224 | mod=mod_name 225 | ) 226 | ) 227 | else: 228 | funcs = inspect.getmembers(mod, inspect.isfunction) 229 | for name, f in funcs: 230 | if name == func_name: 231 | try: 232 | f(channel, payload) 233 | except Exception as err: 234 | logger.debug( 235 | "failed calling notification plugin", exc_info=err 236 | ) 237 | break 238 | else: 239 | logger.debug( 240 | "could not find function '{f}' in plugin '{mod}' " 241 | "for notification".format(mod=mod_name, f=func_name) 242 | ) 243 | -------------------------------------------------------------------------------- /chaoslib/provider/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit/chaostoolkit-lib/61741af75ab0386b0ad3171012cbb028fb596329/chaoslib/provider/__init__.py -------------------------------------------------------------------------------- /chaoslib/provider/http.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | import requests 5 | import urllib3 6 | 7 | from chaoslib import substitute 8 | from chaoslib.exceptions import ActivityFailed, InvalidActivity 9 | from chaoslib.types import Activity, Configuration, Secrets 10 | 11 | __all__ = ["run_http_activity", "validate_http_activity"] 12 | logger = logging.getLogger("chaostoolkit") 13 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 14 | 15 | 16 | def run_http_activity( 17 | activity: Activity, configuration: Configuration, secrets: Secrets 18 | ) -> Any: 19 | """ 20 | Run a HTTP activity. 21 | 22 | A HTTP activity is a call to a HTTP endpoint and its result is returned as 23 | the raw result of this activity. 24 | 25 | Raises :exc:`ActivityFailed` when a timeout occurs for the request or when 26 | the endpoint returns a status in the 400 or 500 ranges. 27 | 28 | This should be considered as a private function. 29 | """ 30 | provider = activity["provider"] 31 | url = substitute(provider["url"], configuration, secrets) 32 | method = provider.get("method", "GET").upper() 33 | headers = substitute(provider.get("headers", None), configuration, secrets) 34 | timeout = substitute(provider.get("timeout", None), configuration, secrets) 35 | arguments = provider.get("arguments", None) 36 | verify_tls = provider.get("verify_tls", True) 37 | max_retries = provider.get("max_retries", 0) 38 | 39 | if arguments and (configuration or secrets): 40 | arguments = substitute(arguments, configuration, secrets) 41 | 42 | if isinstance(timeout, list): 43 | timeout = tuple(timeout) 44 | 45 | try: 46 | s = requests.Session() 47 | a = requests.adapters.HTTPAdapter(max_retries=max_retries) 48 | s.mount("http://", a) 49 | s.mount("https://", a) 50 | if method == "GET": 51 | r = s.get( 52 | url, 53 | params=arguments, 54 | headers=headers, 55 | timeout=timeout, 56 | verify=verify_tls, 57 | ) 58 | else: 59 | if headers and headers.get("Content-Type") == "application/json": 60 | r = s.request( 61 | method, 62 | url, 63 | json=arguments, 64 | headers=headers, 65 | timeout=timeout, 66 | verify=verify_tls, 67 | ) 68 | else: 69 | r = s.request( 70 | method, 71 | url, 72 | data=arguments, 73 | headers=headers, 74 | timeout=timeout, 75 | verify=verify_tls, 76 | ) 77 | 78 | body = None 79 | if r.headers.get("Content-Type") == "application/json": 80 | body = r.json() 81 | else: 82 | body = r.text 83 | 84 | # kind warning to the user that this HTTP call may be invalid 85 | # but not during the hypothesis check because that could also be 86 | # exactly what the user want. This warning is helpful during the 87 | # method and rollbacks 88 | if "tolerance" not in activity and r.status_code > 399: 89 | logger.warning( 90 | "This HTTP call returned a response with a HTTP status code " 91 | "above 400. This may indicate some error and not " 92 | "what you expected. Please have a look at the logs." 93 | ) 94 | 95 | return { 96 | "status": r.status_code, 97 | "headers": dict(**r.headers), 98 | "body": body, 99 | } 100 | except requests.exceptions.ConnectionError as cex: 101 | raise ActivityFailed(f"failed to connect to {url}: {str(cex)}") 102 | except requests.exceptions.Timeout: 103 | raise ActivityFailed("activity took too long to complete") 104 | 105 | 106 | def validate_http_activity(activity: Activity): 107 | """ 108 | Validate a HTTP activity. 109 | 110 | A process activity requires: 111 | 112 | * a `"url"` key which is the address to call 113 | 114 | In addition, you can pass the followings: 115 | 116 | * `"method"` which is the HTTP verb to use (default to `"GET"`) 117 | * `"headers"` which must be a mapping of string to string 118 | 119 | In all failing cases, raises :exc:`InvalidActivity`. 120 | 121 | This should be considered as a private function. 122 | """ 123 | provider = activity["provider"] 124 | url = provider.get("url") 125 | if not url: 126 | raise InvalidActivity("a HTTP activity must have a URL") 127 | 128 | headers = provider.get("headers") 129 | if headers and not isinstance(headers, dict): 130 | raise InvalidActivity("a HTTP activities expect headers as a mapping") 131 | -------------------------------------------------------------------------------- /chaoslib/provider/process.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import logging 3 | import os 4 | import os.path 5 | import shutil 6 | import subprocess 7 | from typing import Any 8 | 9 | from chaoslib import decode_bytes, substitute 10 | from chaoslib.exceptions import ActivityFailed, InvalidActivity 11 | from chaoslib.types import Activity, Configuration, Secrets 12 | 13 | __all__ = ["run_process_activity", "validate_process_activity"] 14 | logger = logging.getLogger("chaostoolkit") 15 | 16 | 17 | def run_process_activity( 18 | activity: Activity, configuration: Configuration, secrets: Secrets 19 | ) -> Any: 20 | """ 21 | Run the a process activity. 22 | 23 | A process activity is an executable the current user is allowed to apply. 24 | The raw result of that command is returned as bytes of this activity. 25 | 26 | Raises :exc:`ActivityFailed` when a the process takes longer than the 27 | timeout defined in the activity. There is no timeout by default so be 28 | careful when you do not explicitly provide one. 29 | 30 | This should be considered as a private function. 31 | """ 32 | provider = activity["provider"] 33 | timeout = provider.get("timeout", None) 34 | arguments = provider.get("arguments", []) 35 | 36 | if arguments and (configuration or secrets): 37 | arguments = substitute(arguments, configuration, secrets) 38 | 39 | shell = False 40 | path = shutil.which(os.path.expanduser(provider["path"])) 41 | if isinstance(arguments, str): 42 | shell = True 43 | arguments = f"{path} {arguments}" 44 | else: 45 | if isinstance(arguments, dict): 46 | arguments = itertools.chain.from_iterable(arguments.items()) 47 | 48 | arguments = list(str(p) for p in arguments if p not in (None, "")) 49 | arguments.insert(0, path) 50 | 51 | try: 52 | logger.debug(f"Running: {str(arguments)}") 53 | proc = subprocess.run( 54 | arguments, 55 | timeout=timeout, 56 | stdout=subprocess.PIPE, 57 | stderr=subprocess.PIPE, 58 | env=os.environ, 59 | shell=shell, 60 | ) 61 | except subprocess.TimeoutExpired: 62 | raise ActivityFailed("process activity took too long to complete") 63 | 64 | # kind warning to the user that this process returned a non--zero 65 | # exit code, as traditionally used to indicate a failure, 66 | # but not during the hypothesis check because that could also be 67 | # exactly what the user want. This warning is helpful during the 68 | # method and rollbacks 69 | if "tolerance" not in activity and proc.returncode > 0: 70 | logger.warning( 71 | "This process returned a non-zero exit code. " 72 | "This may indicate some error and not what you expected. " 73 | "Please have a look at the logs." 74 | ) 75 | 76 | stdout = decode_bytes(proc.stdout) 77 | stderr = decode_bytes(proc.stderr) 78 | 79 | return {"status": proc.returncode, "stdout": stdout, "stderr": stderr} 80 | 81 | 82 | def validate_process_activity(activity: Activity): 83 | """ 84 | Validate a process activity. 85 | 86 | A process activity requires: 87 | 88 | * a `"path"` key which is an absolute path to an executable the current 89 | user can call 90 | 91 | In all failing cases, raises :exc:`InvalidActivity`. 92 | 93 | This should be considered as a private function. 94 | """ 95 | name = activity["name"] 96 | provider = activity["provider"] 97 | 98 | path = raw_path = provider.get("path") 99 | if not path: 100 | raise InvalidActivity("a process activity must have a path") 101 | 102 | path = shutil.which(path) 103 | if not path: 104 | raise InvalidActivity( 105 | "path '{path}' cannot be found, in activity '{name}'".format( 106 | path=raw_path, name=name 107 | ) 108 | ) 109 | 110 | if not os.access(path, os.X_OK): 111 | raise InvalidActivity( 112 | "no access permission to '{path}', in activity '{name}'".format( 113 | path=raw_path, name=name 114 | ) 115 | ) 116 | -------------------------------------------------------------------------------- /chaoslib/provider/python.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import logging 4 | import sys 5 | import traceback 6 | from typing import Any 7 | 8 | from chaoslib import substitute 9 | from chaoslib.exceptions import ActivityFailed, InvalidActivity 10 | from chaoslib.types import Activity, Configuration, Secrets 11 | 12 | __all__ = ["run_python_activity", "validate_python_activity"] 13 | logger = logging.getLogger("chaostoolkit") 14 | 15 | 16 | def run_python_activity( 17 | activity: Activity, configuration: Configuration, secrets: Secrets 18 | ) -> Any: 19 | """ 20 | Run a Python activity. 21 | 22 | A python activity is a function from any importable module. The result 23 | of that function is returned as the activity's output. 24 | 25 | This should be considered as a private function. 26 | """ 27 | provider = activity["provider"] 28 | mod_path = provider["module"] 29 | func_name = provider["func"] 30 | mod = importlib.import_module(mod_path) 31 | func = getattr(mod, func_name) 32 | try: 33 | logger.debug( 34 | "Activity '{}' loaded from '{}'".format( 35 | activity.get("name"), inspect.getfile(func) 36 | ) 37 | ) 38 | except TypeError: 39 | pass 40 | 41 | arguments = provider.get("arguments", {}).copy() 42 | 43 | if configuration or secrets: 44 | arguments = substitute(arguments, configuration, secrets) 45 | 46 | sig = inspect.signature(func) 47 | if "secrets" in provider and "secrets" in sig.parameters: 48 | arguments["secrets"] = {} 49 | for s in provider["secrets"]: 50 | arguments["secrets"].update(secrets.get(s, {}).copy()) 51 | 52 | if "configuration" in sig.parameters: 53 | arguments["configuration"] = configuration.copy() 54 | 55 | try: 56 | return func(**arguments) 57 | except Exception as x: 58 | raise ActivityFailed( 59 | traceback.format_exception_only(type(x), x)[0].strip() 60 | ).with_traceback(sys.exc_info()[2]) 61 | 62 | 63 | def validate_python_activity(activity: Activity): # noqa: C901 64 | """ 65 | Validate a Python activity. 66 | 67 | A Python activity requires: 68 | 69 | * a `"module"` key which is an absolute Python dotted path for a Python 70 | module this process can import 71 | * a `func"` key which is the name of a function in that module 72 | 73 | The `"arguments"` activity key must match the function's signature. 74 | 75 | In all failing cases, raises :exc:`InvalidActivity`. 76 | 77 | This should be considered as a private function. 78 | """ 79 | activity_name = activity["name"] 80 | provider = activity["provider"] 81 | mod_name = provider.get("module") 82 | if not mod_name: 83 | raise InvalidActivity("a Python activity must have a module path") 84 | 85 | func = provider.get("func") 86 | if not func: 87 | raise InvalidActivity("a Python activity must have a function name") 88 | 89 | try: 90 | mod = importlib.import_module(mod_name) 91 | except ImportError: 92 | raise InvalidActivity( 93 | "could not find Python module '{mod}' " 94 | "in activity '{name}'".format(mod=mod_name, name=activity_name) 95 | ) 96 | 97 | found_func = False 98 | arguments = provider.get("arguments", {}) 99 | candidates = set(inspect.getmembers(mod, inspect.isfunction)).union( 100 | inspect.getmembers(mod, inspect.isbuiltin) 101 | ) 102 | for name, cb in candidates: 103 | if name == func: 104 | found_func = True 105 | 106 | # let's try to bind the activity's arguments with the function 107 | # signature see if they match 108 | sig = inspect.signature(cb) 109 | try: 110 | # config and secrets are provided through specific parameters 111 | # to an activity that needs them. However, they are declared 112 | # out of band of the `arguments` mapping. Here, we simply 113 | # ensure the signature of the activity is valid by injecting 114 | # fake `configuration` and `secrets` arguments into the mapping 115 | args = arguments.copy() 116 | 117 | if "secrets" in sig.parameters: 118 | args["secrets"] = None 119 | 120 | if "configuration" in sig.parameters: 121 | args["configuration"] = None 122 | 123 | sig.bind(**args) 124 | except TypeError as x: 125 | # I dislike this sort of lookup but not sure we can 126 | # differentiate them otherwise 127 | msg = str(x) 128 | if "missing" in msg: 129 | arg = msg.rsplit(":", 1)[1].strip() 130 | raise InvalidActivity( 131 | "required argument {arg} is missing from " 132 | "activity '{name}'".format(arg=arg, name=name) 133 | ) 134 | elif "unexpected" in msg: 135 | arg = msg.rsplit(" ", 1)[1].strip() 136 | raise InvalidActivity( 137 | "argument {arg} is not part of the " 138 | "function signature in activity '{name}'".format( 139 | arg=arg, name=name 140 | ) 141 | ) 142 | else: 143 | # another error? let's fail fast 144 | raise 145 | break 146 | 147 | if not found_func: 148 | raise InvalidActivity( 149 | "The python module '{mod}' does not expose a function called " 150 | "'{func}' in {type} '{name}'".format( 151 | mod=mod_name, 152 | func=func, 153 | type=activity["type"], 154 | name=activity_name, 155 | ) 156 | ) 157 | -------------------------------------------------------------------------------- /chaoslib/rollback.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from concurrent.futures import ThreadPoolExecutor 3 | from typing import TYPE_CHECKING, Iterator, List 4 | 5 | from chaoslib.activity import execute_activity 6 | from chaoslib.types import Configuration, Dry, Experiment, Run, Secrets 7 | 8 | if TYPE_CHECKING: 9 | from chaoslib.run import EventHandlerRegistry 10 | 11 | __all__ = ["run_rollbacks"] 12 | 13 | logger = logging.getLogger("chaostoolkit") 14 | 15 | 16 | def run_rollbacks( 17 | experiment: Experiment, 18 | configuration: Configuration, 19 | secrets: Secrets, 20 | pool: ThreadPoolExecutor, 21 | dry: Dry, 22 | event_registry: "EventHandlerRegistry" = None, 23 | runs: List[Run] = None, 24 | ) -> Iterator[Run]: 25 | """ 26 | Run all rollbacks declared in the experiment in their order. Wait for 27 | each rollback activity to complete before to the next unless the activity 28 | is declared with the `background` flag. 29 | """ 30 | rollbacks = experiment.get("rollbacks", []) 31 | 32 | if not rollbacks: 33 | logger.info("No declared rollbacks, let's move on.") 34 | 35 | for activity in rollbacks: 36 | logger.info("Rollback: {t}".format(t=activity.get("name"))) 37 | 38 | if activity.get("background"): 39 | logger.debug("rollback activity will run in the background") 40 | yield pool.submit( 41 | execute_activity, 42 | experiment=experiment, 43 | activity=activity, 44 | configuration=configuration, 45 | secrets=secrets, 46 | dry=dry, 47 | event_registry=event_registry, 48 | runs=runs, 49 | ) 50 | else: 51 | yield execute_activity( 52 | experiment, 53 | activity, 54 | configuration=configuration, 55 | secrets=secrets, 56 | dry=dry, 57 | event_registry=event_registry, 58 | runs=runs, 59 | ) 60 | -------------------------------------------------------------------------------- /chaoslib/secret.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Any, Dict 4 | 5 | try: 6 | import hvac 7 | 8 | HAS_HVAC = True 9 | except ImportError: 10 | HAS_HVAC = False 11 | 12 | from chaoslib.exceptions import InvalidExperiment 13 | from chaoslib.types import Configuration, Secrets 14 | 15 | __all__ = ["load_secrets", "create_vault_client"] 16 | 17 | logger = logging.getLogger("chaostoolkit") 18 | 19 | 20 | def load_secrets( 21 | secrets_info: Dict[str, Dict[str, str]], 22 | configuration: Configuration = None, 23 | extra_vars: Dict[str, Any] = None, 24 | ) -> Secrets: 25 | """ 26 | Takes the the secrets definition from an experiment and tries to load 27 | the secrets whenever they relate to external sources such as environmental 28 | variables (or in the future from vault secrets). 29 | 30 | Here is an example of what it looks like: 31 | 32 | ``` 33 | { 34 | "target_1": { 35 | "mysecret_1": "some value" 36 | }, 37 | "target_2": { 38 | "mysecret_2": { 39 | "type": "env", 40 | "key": "SOME_ENV_VAR" 41 | } 42 | }, 43 | "target_3": { 44 | "mysecret_3": { 45 | "type": "vault", 46 | "key": "secrets/some/key" 47 | } 48 | } 49 | } 50 | ``` 51 | 52 | Loading this secrets definition will generate the following: 53 | 54 | ``` 55 | { 56 | "target_1": { 57 | "mysecret_1": "some value" 58 | }, 59 | "target_2": { 60 | "mysecret_2": "some other value" 61 | }, 62 | "target_3": { 63 | "mysecret_3": "some alternate value" 64 | } 65 | } 66 | ``` 67 | 68 | You can refer to those from your experiments: 69 | 70 | ``` 71 | { 72 | "type": "probe", 73 | "provider": { 74 | "secret": ["target_1", "target_2"] 75 | } 76 | } 77 | ``` 78 | """ 79 | logger.debug("Loading secrets...") 80 | 81 | extra_vars = extra_vars or {} 82 | 83 | secrets = {} 84 | 85 | for key, value in secrets_info.items(): 86 | if isinstance(value, dict): 87 | if extra_vars.get(key, None) is not None: 88 | secrets[key] = extra_vars.get(key) 89 | 90 | elif value.get("type") == "env": 91 | secrets[key] = load_secret_from_env(value) 92 | 93 | elif value.get("type") == "vault": 94 | secrets[key] = load_secrets_from_vault(value, configuration) 95 | 96 | else: 97 | secrets[key] = load_secrets( 98 | value, configuration, extra_vars.get(key, None) 99 | ) 100 | 101 | else: 102 | secrets[key] = value 103 | 104 | logger.debug("Done loading secrets") 105 | 106 | return secrets 107 | 108 | 109 | def load_secret_from_env(secrets_info: Dict[str, Dict[str, str]]) -> Secrets: 110 | env = os.environ 111 | 112 | if isinstance(secrets_info, dict) and secrets_info.get("type") == "env": 113 | env_key = secrets_info["key"] 114 | if env_key not in env: 115 | raise InvalidExperiment( 116 | "Secrets make reference to an environment key " 117 | "that does not exist: {}".format(env_key) 118 | ) 119 | else: 120 | secret = env[env_key] 121 | 122 | return secret 123 | 124 | 125 | def load_secrets_from_vault( 126 | secrets_info: Dict[str, Dict[str, str]], # noqa: C901 127 | configuration: Configuration = None, 128 | ) -> Secrets: 129 | """ 130 | Load secrets from Vault KV secrets store 131 | 132 | In your experiment: 133 | 134 | ``` 135 | { 136 | "k8s": { 137 | "mykey": { 138 | "type": "vault", 139 | "path": "foo/bar" 140 | } 141 | } 142 | } 143 | ``` 144 | 145 | This will read the Vault secret at path `secret/foo/bar` 146 | (or `secret/data/foo/bar` if you use Vault KV version 2) and store its 147 | entirely payload into Chaos Toolkit `mykey`. This means, that all kays 148 | under that path will be available as-is. For instance, this could be: 149 | 150 | ``` 151 | { 152 | "mypassword": "shhh", 153 | "mylogin": "jane 154 | } 155 | ``` 156 | 157 | You may be more specific as follows: 158 | 159 | ``` 160 | { 161 | "k8s": { 162 | "mykey": { 163 | "type": "vault", 164 | "path": "foo/bar", 165 | "key": "mypassword" 166 | } 167 | } 168 | } 169 | ``` 170 | 171 | In that case, `mykey` will be set to the value at `secret/foo/bar` under 172 | the Vault secret key `mypassword`. 173 | """ 174 | 175 | secret = {} 176 | 177 | client = create_vault_client(configuration) 178 | 179 | if isinstance(secrets_info, dict) and secrets_info.get("type") == "vault": 180 | if not HAS_HVAC: 181 | logger.error( 182 | "Install the `hvac` package to fetch secrets " 183 | "from Vault: `pip install chaostoolkit-lib[vault]`." 184 | ) 185 | return {} 186 | 187 | vault_path = secrets_info.get("path") 188 | 189 | if vault_path is None: 190 | logger.warning(f"Missing Vault secret path for '{secrets_info}'") 191 | return {} 192 | 193 | # see https://github.com/chaostoolkit/chaostoolkit/issues/98 194 | kv = client.secrets.kv 195 | is_kv1 = kv.default_kv_version == "1" 196 | if is_kv1: 197 | vault_payload = kv.v1.read_secret( 198 | path=vault_path, 199 | mount_point=configuration.get( 200 | "vault_secrets_mount_point", "secret" 201 | ), 202 | ) 203 | else: 204 | vault_payload = kv.v2.read_secret_version( 205 | path=vault_path, 206 | mount_point=configuration.get( 207 | "vault_secrets_mount_point", "secret" 208 | ), 209 | ) 210 | 211 | if not vault_payload: 212 | logger.warning(f"No Vault secret found at path: {vault_path}") 213 | return {} 214 | 215 | if is_kv1: 216 | data = vault_payload.get("data") 217 | else: 218 | data = vault_payload.get("data", {}).get("data") 219 | 220 | key = secrets_info.get("key") 221 | 222 | if key is not None: 223 | secret = data[key] 224 | else: 225 | secret = data 226 | 227 | return secret 228 | 229 | 230 | ############################################################################### 231 | # Internals 232 | ############################################################################### 233 | def create_vault_client(configuration: Configuration = None): 234 | """ 235 | Initialize a Vault client from either a token or an approle. 236 | """ 237 | client = None 238 | if HAS_HVAC: 239 | url = configuration.get("vault_addr") 240 | client = hvac.Client(url=url) 241 | 242 | client.secrets.kv.default_kv_version = str( 243 | configuration.get("vault_kv_version", "2") 244 | ) 245 | logger.debug( 246 | "Using Vault secrets KV version {}".format( 247 | client.secrets.kv.default_kv_version 248 | ) 249 | ) 250 | 251 | if "vault_token" in configuration: 252 | client.token = configuration.get("vault_token") 253 | elif ( 254 | "vault_role_id" in configuration 255 | and "vault_role_secret" in configuration 256 | ): 257 | role_id = configuration.get("vault_role_id") 258 | role_secret = configuration.get("vault_role_secret") 259 | 260 | try: 261 | app_role = client.auth_approle(role_id, role_secret) 262 | except Exception as ve: 263 | raise InvalidExperiment( 264 | f"Failed to connect to Vault with the AppRole: {str(ve)}" 265 | ) 266 | 267 | client.token = app_role["auth"]["client_token"] 268 | elif "vault_sa_role" in configuration: 269 | sa_token_path = ( 270 | configuration.get("vault_sa_token_path", "") 271 | or "/var/run/secrets/kubernetes.io/serviceaccount/token" 272 | ) 273 | 274 | mount_point = configuration.get( 275 | "vault_k8s_mount_point", "kubernetes" 276 | ) 277 | 278 | try: 279 | with open(sa_token_path) as sa_token: 280 | jwt = sa_token.read() 281 | role = configuration.get("vault_sa_role") 282 | client.auth_kubernetes( 283 | role=role, 284 | jwt=jwt, 285 | use_token=True, 286 | mount_point=mount_point, 287 | ) 288 | except OSError: 289 | raise InvalidExperiment( 290 | "Failed to get service account token at: {path}".format( 291 | path=sa_token_path 292 | ) 293 | ) 294 | except Exception as e: 295 | raise InvalidExperiment( 296 | "Failed to connect to Vault using service account with " 297 | "errors: '{errors}'".format(errors=str(e)) 298 | ) 299 | 300 | return client 301 | -------------------------------------------------------------------------------- /chaoslib/settings.py: -------------------------------------------------------------------------------- 1 | import contextvars 2 | import logging 3 | import os 4 | import os.path 5 | import re 6 | from typing import Any, Dict, List, Optional, Tuple, Union 7 | 8 | import yaml 9 | 10 | from chaoslib.types import Settings 11 | 12 | __all__ = [ 13 | "get_loaded_settings", 14 | "load_settings", 15 | "save_settings", 16 | "locate_settings_entry", 17 | ] 18 | CHAOSTOOLKIT_CONFIG_PATH = os.path.abspath( 19 | os.path.expanduser("~/.chaostoolkit/settings.yaml") 20 | ) 21 | loaded_settings = contextvars.ContextVar("loaded_settings", default={}) 22 | logger = logging.getLogger("chaostoolkit") 23 | 24 | 25 | def load_settings(settings_path: str = CHAOSTOOLKIT_CONFIG_PATH) -> Settings: 26 | """ 27 | Load chaostoolkit settings as a mapping of key/values or return `None` 28 | when the file could not be found. 29 | """ 30 | if not os.path.exists(settings_path): 31 | logger.debug( 32 | "The Chaos Toolkit settings file could not be found at " 33 | "'{c}'.".format(c=settings_path) 34 | ) 35 | return 36 | 37 | with open(settings_path) as f: 38 | try: 39 | settings = yaml.safe_load(f.read()) 40 | loaded_settings.set(settings) 41 | return settings 42 | except yaml.YAMLError as ye: 43 | logger.error(f"Failed parsing YAML settings: {str(ye)}") 44 | 45 | 46 | def save_settings( 47 | settings: Settings, settings_path: str = CHAOSTOOLKIT_CONFIG_PATH 48 | ): 49 | """ 50 | Save chaostoolkit settings as a mapping of key/values, overwriting any file 51 | that may already be present. 52 | """ 53 | loaded_settings.set(settings) 54 | settings_dir = os.path.dirname(settings_path) 55 | if not os.path.isdir(settings_dir): 56 | os.mkdir(settings_dir) 57 | 58 | with open(settings_path, "w") as outfile: 59 | yaml.dump(settings, outfile, default_flow_style=False) 60 | 61 | 62 | def get_loaded_settings() -> Settings: 63 | """ 64 | Settings that have been loaded in the current context. 65 | """ 66 | return loaded_settings.get() 67 | 68 | 69 | def locate_settings_entry( 70 | settings: Settings, key: str 71 | ) -> Optional[ 72 | Tuple[Union[Dict[str, Any], List], Any, Optional[str], Optional[int]] 73 | ]: 74 | """ 75 | Lookup the entry at the given dotted key in the provided settings and 76 | return a a tuple as follows: 77 | 78 | * the parent of the found entry (can be a list or a dict) 79 | * the entry (can eb anything: string, int, list, dict) 80 | * the key on the parent that has the entry (in case parent is a dict) 81 | * the index in the parent that has the entry (in case parent is a list) 82 | 83 | Otherwise, returns `None`. 84 | 85 | When the key in the settings has at least one dot, it must be escaped 86 | with two backslahes. 87 | 88 | Examples of valid keys: 89 | 90 | * auths 91 | * auths.example\\.com 92 | * auths.example\\.com:8443 93 | * auths.example\\.com.type 94 | * controls[0].name 95 | """ 96 | array_index = re.compile(r"\[([0-9]*)\]$") 97 | # borrowed from https://github.com/carlosescri/DottedDict (MIT) 98 | # this kindly preserves escaped dots 99 | parts = [x for x in re.split(r"(? "Strategy": 77 | if value == "default": 78 | return Strategy.DEFAULT 79 | elif value == "before-method-only": 80 | return Strategy.BEFORE_METHOD 81 | elif value == "after-method-only": 82 | return Strategy.AFTER_METHOD 83 | elif value == "during-method-only": 84 | return Strategy.DURING_METHOD 85 | elif value == "continuously": 86 | return Strategy.CONTINUOUS 87 | elif value == "skip": 88 | return Strategy.SKIP 89 | 90 | raise ValueError("Unknown strategy") 91 | 92 | 93 | class Dry(enum.Enum): 94 | PROBES = "probes" 95 | ACTIONS = "actions" 96 | ACTIVITIES = "activities" 97 | PAUSE = "pause" 98 | 99 | @staticmethod 100 | def from_string(value: str) -> Optional["Dry"]: 101 | if value == "probes": 102 | return Dry.PROBES 103 | elif value == "actions": 104 | return Dry.ACTIONS 105 | elif value == "activities": 106 | return Dry.ACTIVITIES 107 | elif value == "pause": 108 | return Dry.PAUSE 109 | elif not value: 110 | return None 111 | 112 | raise ValueError("Unknown dry") 113 | 114 | 115 | class Schedule: 116 | def __init__( 117 | self, 118 | continuous_hypothesis_frequency: float = 1.0, 119 | fail_fast: bool = False, 120 | fail_fast_ratio: float = 0, 121 | ): 122 | self.continuous_hypothesis_frequency = continuous_hypothesis_frequency 123 | self.fail_fast = fail_fast 124 | self.fail_fast_ratio = fail_fast_ratio 125 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "chaostoolkit-lib" 7 | dynamic = ["version"] 8 | description = "Chaos Toolkit core library" 9 | authors = [ 10 | {name = "Chaos Toolkit", email = "contact@chaostoolkit.org"}, 11 | {name = "Sylvain Hellegouarch", email = "sh@defuze.org"}, 12 | ] 13 | dependencies = [ 14 | "requests>=2.31.0", 15 | "pyyaml>=6.0.1", 16 | "importlib-metadata>=6.7.0", 17 | "charset-normalizer>=3.3.2", 18 | "python-json-logger>=2.0.7", 19 | "colorama>=0.4.4; sys_platform == \"win32\"", 20 | ] 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Intended Audience :: Developers", 24 | "License :: Freely Distributable", 25 | "License :: OSI Approved :: Apache Software License", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: Implementation", 35 | "Programming Language :: Python :: Implementation :: CPython" 36 | ] 37 | requires-python = ">=3.8" 38 | readme = "README.md" 39 | license = {text = "Apache-2.0"} 40 | 41 | [project.urls] 42 | Homepage = "https://chaostoolkit.org/" 43 | Repository = "https://github.com/chaostoolkit/chaostoolkit-lib" 44 | Documentation = "https://chaostoolkit.org" 45 | Changelog = "https://github.com/chaostoolkit/chaostoolkit-lib/blob/main/CHANGELOG.md" 46 | 47 | [project.optional-dependencies] 48 | jsonpath = [ 49 | "jsonpath2>=0.4.5", 50 | ] 51 | vault = [ 52 | "hvac>=1.2.1", 53 | ] 54 | [tool] 55 | 56 | [tool.pdm] 57 | version = { source = "scm" } 58 | 59 | [tool.pdm.dev-dependencies] 60 | dev = [ 61 | "requests-mock>=1.11.0", 62 | "coverage>=7.2.7", 63 | "pytest>=7.4.4", 64 | "pytest-cov>=4.1.0", 65 | "pytest-sugar>=1.0.0", 66 | "ply>=3.11", 67 | "pyhcl>=0.4.5", 68 | "hvac>=1.2.1", 69 | "jsonpath2>=0.4.5", 70 | "charset-normalizer>=3.3.2", 71 | "ruff>=0.2.2", 72 | "callee>=0.3.1", 73 | "responses>=0.23.3", 74 | "freezegun>=1.4.0", 75 | ] 76 | 77 | [tool.pdm.scripts] 78 | lint = {composite = ["ruff check ."]} 79 | format = {composite = ["ruff check --fix .", "ruff format ."]} 80 | test = {cmd = "pytest"} 81 | 82 | [tool.ruff] 83 | line-length = 80 84 | exclude = [ 85 | ".eggs", 86 | ".git", 87 | ".mypy_cache", 88 | ".pytest_cache", 89 | ".ruff_cache", 90 | ".venv", 91 | ".vscode", 92 | "__pypackages__", 93 | "build", 94 | "dist", 95 | ] 96 | 97 | [tool.ruff.format] 98 | quote-style = "double" 99 | indent-style = "space" 100 | skip-magic-trailing-comma = false 101 | line-ending = "auto" 102 | docstring-code-format = false 103 | 104 | [tool.pytest.ini_options] 105 | minversion = "6.0" 106 | testpaths = "tests" 107 | addopts = "-v -rxs --cov chaoslib --cov-report term-missing:skip-covered -p no:warnings" -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import os.path 4 | from tempfile import NamedTemporaryFile 5 | from typing import Generator 6 | 7 | import pytest 8 | from fixtures import experiments 9 | 10 | from chaoslib.log import configure_logger 11 | from chaoslib.settings import load_settings 12 | from chaoslib.types import Settings 13 | 14 | 15 | @pytest.fixture(scope="session", autouse=True) 16 | def setup_logger() -> None: 17 | configure_logger() 18 | 19 | 20 | @pytest.fixture 21 | def fixtures_dir() -> str: 22 | return os.path.join(os.path.dirname(__file__), "fixtures") 23 | 24 | 25 | @pytest.fixture 26 | def settings_file() -> str: 27 | return os.path.join(os.path.dirname(__file__), "fixtures", "settings.yaml") 28 | 29 | 30 | @pytest.fixture 31 | def settings(settings_file: str) -> Settings: 32 | return load_settings(settings_file) 33 | 34 | 35 | @pytest.fixture 36 | def generic_experiment() -> Generator[str, None, None]: 37 | with NamedTemporaryFile(mode="w", suffix=".json", encoding="utf-8") as f: 38 | json.dump(experiments.Experiment, f) 39 | f.seek(0) 40 | yield f.name 41 | 42 | 43 | @pytest.fixture(autouse=True) 44 | def reset_env() -> None: 45 | e = os.environ.copy() 46 | try: 47 | yield 48 | finally: 49 | os.environ.clear() 50 | os.environ.update(e) 51 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit/chaostoolkit-lib/61741af75ab0386b0ad3171012cbb028fb596329/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/actions.py: -------------------------------------------------------------------------------- 1 | import os 2 | from copy import deepcopy 3 | 4 | EmptyAction = {} 5 | 6 | 7 | DoNothingAction = { 8 | "name": "a name", 9 | "type": "action", 10 | "provider": { 11 | "type": "python", 12 | "module": "fixtures.fakeext", 13 | "func": "do_nothing", 14 | }, 15 | } 16 | 17 | EchoAction = { 18 | "name": "a name", 19 | "type": "action", 20 | "provider": { 21 | "type": "python", 22 | "module": "fixtures.fakeext", 23 | "func": "echo_message", 24 | "arguments": {"message": "kaboom"}, 25 | }, 26 | } 27 | 28 | 29 | FailAction = { 30 | "name": "a name", 31 | "type": "action", 32 | "provider": { 33 | "type": "python", 34 | "module": "fixtures.fakeext", 35 | "func": "force_failed_activity", 36 | }, 37 | } 38 | 39 | 40 | InterruptAction = { 41 | "name": "a name", 42 | "type": "action", 43 | "provider": { 44 | "type": "python", 45 | "module": "fixtures.fakeext", 46 | "func": "force_interrupting_experiment", 47 | }, 48 | } 49 | 50 | PythonModuleActionWithLongPause = { 51 | "type": "action", 52 | "name": "action-with-long-pause", 53 | "pauses": {"before": 30, "after": 5}, 54 | "provider": { 55 | "type": "python", 56 | "module": "os.path", 57 | "func": "exists", 58 | "arguments": { 59 | "path": os.path.abspath(__file__), 60 | }, 61 | "timeout": 40, 62 | }, 63 | } 64 | 65 | PythonModuleActionWithLongAction = deepcopy(PythonModuleActionWithLongPause) 66 | PythonModuleActionWithLongAction["pauses"]["after"] = 30 67 | PythonModuleActionWithLongAction["pauses"]["before"] = 35 68 | -------------------------------------------------------------------------------- /tests/fixtures/badstuff.py: -------------------------------------------------------------------------------- 1 | from itertools import count 2 | 3 | from chaoslib.exceptions import InterruptExecution 4 | 5 | __all__ = [ 6 | "interrupt_me", 7 | "raise_exception", 8 | "check_under_treshold", 9 | "count_generator", 10 | ] 11 | 12 | 13 | def interrupt_me(): 14 | raise InterruptExecution() 15 | 16 | 17 | def raise_exception(): 18 | raise Exception("oops") 19 | 20 | 21 | g = None 22 | 23 | 24 | def count_generator(): 25 | global g 26 | if g is None: 27 | g = count() 28 | 29 | return next(g) 30 | 31 | 32 | def cleanup_counter(): 33 | global g 34 | g = None 35 | 36 | 37 | def check_under_treshold(value: int = 0, target: int = 5) -> bool: 38 | global g 39 | if value >= target: 40 | return False 41 | return True 42 | -------------------------------------------------------------------------------- /tests/fixtures/config.py: -------------------------------------------------------------------------------- 1 | EmptyConfig = {} 2 | 3 | SomeConfig = {"name": "Jane", "age": 34, "path": {"type": "env", "key": "PATH"}} 4 | -------------------------------------------------------------------------------- /tests/fixtures/configprobe.py: -------------------------------------------------------------------------------- 1 | def raise_exception(): 2 | raise RuntimeError("booom") 3 | -------------------------------------------------------------------------------- /tests/fixtures/controls/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit/chaostoolkit-lib/61741af75ab0386b0ad3171012cbb028fb596329/tests/fixtures/controls/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from chaoslib.types import ( 4 | Activity, 5 | Configuration, 6 | Experiment, 7 | Hypothesis, 8 | Journal, 9 | Run, 10 | Secrets, 11 | Settings, 12 | ) 13 | 14 | 15 | def configure_control( 16 | experiment: Experiment, 17 | configuration: Configuration, 18 | secrets: Secrets, 19 | settings: Settings, 20 | ): 21 | if configuration: 22 | experiment["control-value"] = configuration.get("dummy-key", "default") 23 | elif settings: 24 | experiment["control-value"] = settings.get("dummy-key", "default") 25 | 26 | 27 | def cleanup_control(): 28 | pass 29 | 30 | 31 | def before_experiment_control(context: Experiment, **kwargs): 32 | context["before_experiment_control"] = True 33 | 34 | 35 | def after_experiment_control(context: Experiment, state: Journal, **kwargs): 36 | context["after_experiment_control"] = True 37 | state["after_experiment_control"] = True 38 | 39 | 40 | def before_hypothesis_control(context: Hypothesis, **kwargs): 41 | context["before_hypothesis_control"] = True 42 | 43 | 44 | def after_hypothesis_control( 45 | context: Hypothesis, state: Dict[str, Any], **kwargs 46 | ): 47 | context["after_hypothesis_control"] = True 48 | state["after_hypothesis_control"] = True 49 | 50 | 51 | def before_method_control(context: Experiment, **kwargs): 52 | context["before_method_control"] = True 53 | 54 | 55 | def after_method_control(context: Experiment, state: List[Run], **kwargs): 56 | context["after_method_control"] = True 57 | state.append("after_method_control") 58 | 59 | 60 | def before_rollback_control(context: Experiment, **kwargs): 61 | context["before_rollback_control"] = True 62 | 63 | 64 | def after_rollback_control(context: Experiment, state: List[Run], **kwargs): 65 | context["after_rollback_control"] = True 66 | state.append("after_rollback_control") 67 | 68 | 69 | def before_activity_control(context: Activity, **kwargs): 70 | context["before_activity_control"] = True 71 | 72 | 73 | def after_activity_control(context: Activity, state: Run, **kwargs): 74 | context["after_activity_control"] = True 75 | state["after_activity_control"] = True 76 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_args_in_control_init.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Configuration, Experiment, Secrets, Settings 2 | 3 | 4 | def configure_control( 5 | experiment: Experiment, 6 | configuration: Configuration, 7 | secrets: Secrets, 8 | settings: Settings, 9 | joke: str, 10 | ): 11 | experiment["joke"] = joke 12 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_changed_configuration.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Activity, Configuration, Run, Secrets 2 | 3 | 4 | def after_activity_control( 5 | context: Activity, 6 | state: Run, 7 | configuration: Configuration = None, 8 | secrets: Secrets = None, 9 | **kwargs, 10 | ): 11 | if context["name"] == "generate-token": 12 | configuration["my_token"] = state["output"] 13 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_changed_secrets.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Activity, Configuration, Run, Secrets 2 | 3 | 4 | def after_activity_control( 5 | context: Activity, 6 | state: Run, 7 | configuration: Configuration = None, 8 | secrets: Secrets = None, 9 | **kwargs, 10 | ): 11 | if context["name"] == "generate-token": 12 | secrets["mytokens"]["my_token"] = state["output"] 13 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_fail_loading_experiment.py: -------------------------------------------------------------------------------- 1 | from chaoslib.exceptions import InterruptExecution 2 | 3 | 4 | def before_loading_experiment_control(context: str): 5 | raise InterruptExecution(f"failed to load: {context}") 6 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_need_access_to_end_state.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Experiment, Journal 2 | 3 | 4 | def after_hypothesis_control(context: Experiment, state: Journal, **kwargs): 5 | state["after_hypothesis_control"] = True 6 | 7 | 8 | def after_experiment_control(context: Experiment, state: Journal, **kwargs): 9 | state["after_experiment_control"] = True 10 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_position_1.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Experiment 2 | 3 | 4 | def before_experiment_control(context: Experiment) -> None: 5 | context.setdefault("position", []).append(1) 6 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_position_2.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Experiment 2 | 3 | 4 | def before_experiment_control(context: Experiment) -> None: 5 | context.setdefault("position", []).append(2) 6 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_position_3.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Experiment 2 | 3 | 4 | def before_experiment_control(context: Experiment) -> None: 5 | context.setdefault("position", []).append(3) 6 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_retitle_experiment_on_loading.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Experiment 2 | 3 | 4 | def after_loading_experiment_control(context: str, state: Experiment): 5 | state["title"] = "BOOM I changed it" 6 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_sums.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from chaoslib.types import Experiment, Journal 4 | 5 | 6 | def before_experiment_control( 7 | context: Experiment, values: Sequence[int] 8 | ) -> None: 9 | context["result_after"] = 0 10 | 11 | 12 | def after_experiment_control( 13 | context: Experiment, state: Journal, values: Sequence[int] 14 | ) -> None: 15 | context["result_after"] += sum(values) 16 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_validator.py: -------------------------------------------------------------------------------- 1 | from chaoslib.exceptions import InvalidActivity 2 | from chaoslib.types import Control 3 | 4 | 5 | def validate_control(control: Control) -> None: 6 | if "should-not-be-here" in control: 7 | raise InvalidActivity("invalid key on control") 8 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_with_decorated_control.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import wraps 3 | from itertools import count 4 | from typing import Callable 5 | 6 | from chaoslib.types import Journal 7 | 8 | logger = logging.getLogger("chaostoolkit") 9 | counter = None 10 | 11 | 12 | def initcounter(f: Callable) -> Callable: 13 | @wraps(f) 14 | def wrapped(*args, **kwargs) -> None: 15 | global counter 16 | counter = count() 17 | f(*args, **kwargs) 18 | 19 | return wrapped 20 | 21 | 22 | def keepcount(f: Callable) -> Callable: 23 | @wraps(f) 24 | def wrapped(*args, **kwargs) -> None: 25 | next(counter) 26 | f(*args, **kwargs) 27 | 28 | return wrapped 29 | 30 | 31 | @keepcount 32 | def after_activity_control(**kwargs): 33 | logger.info("Activity is called") 34 | 35 | 36 | @initcounter 37 | def configure_control(**kwargs): 38 | logger.info("configure is called") 39 | 40 | 41 | def after_experiment_control(state: Journal, **kwargs): 42 | state["counted_activities"] = next(counter) 43 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_with_exited_activity.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Activity 2 | 3 | 4 | def before_activity_control( 5 | context: Activity, target_activity_name: str, **kwargs 6 | ): 7 | if context.get("name") == target_activity_name: 8 | raise SystemExit("we are done here") 9 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_with_experiment.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from chaoslib.types import ( 4 | Activity, 5 | Configuration, 6 | Experiment, 7 | Hypothesis, 8 | Journal, 9 | Run, 10 | Secrets, 11 | ) 12 | 13 | value_from_config = None 14 | 15 | 16 | def configure_control(configuration: Configuration, secrets: Secrets): 17 | global value_from_config 18 | value_from_config = configuration.get("dummy-key", "default") 19 | 20 | 21 | def cleanup_control(): 22 | global value_from_config 23 | value_from_config = None 24 | 25 | 26 | def before_experiment_control(context: Experiment, **kwargs): 27 | context["before_experiment_control"] = True 28 | 29 | 30 | def after_experiment_control(context: Experiment, state: Journal, **kwargs): 31 | context["after_experiment_control"] = True 32 | state["after_experiment_control"] = True 33 | 34 | 35 | def before_hypothesis_control( 36 | experiment: Experiment, context: Hypothesis, **kwargs 37 | ): 38 | context["before_hypothesis_control"] = True 39 | context["has_experiment_before"] = experiment is not None 40 | 41 | 42 | def after_hypothesis_control( 43 | experiment: Experiment, context: Hypothesis, state: Dict[str, Any], **kwargs 44 | ): 45 | context["after_hypothesis_control"] = True 46 | state["after_hypothesis_control"] = True 47 | context["has_experiment_after"] = experiment is not None 48 | 49 | 50 | def before_method_control(context: Experiment, **kwargs): 51 | context["before_method_control"] = True 52 | 53 | 54 | def after_method_control(context: Experiment, state: List[Run], **kwargs): 55 | context["after_method_control"] = True 56 | state.append("after_method_control") 57 | 58 | 59 | def before_rollback_control(context: Experiment, **kwargs): 60 | context["before_rollback_control"] = True 61 | 62 | 63 | def after_rollback_control(context: Experiment, state: List[Run], **kwargs): 64 | context["after_rollback_control"] = True 65 | state.append("after_rollback_control") 66 | 67 | 68 | def before_activity_control( 69 | experiment: Experiment, context: Activity, **kwargs 70 | ): 71 | context["before_activity_control"] = True 72 | context["has_experiment_before"] = experiment is not None 73 | 74 | 75 | def after_activity_control( 76 | experiment: Experiment, context: Activity, state: Run, **kwargs 77 | ): 78 | context["after_activity_control"] = True 79 | state["after_activity_control"] = True 80 | context["has_experiment_after"] = experiment is not None 81 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_with_failing_cleanup.py: -------------------------------------------------------------------------------- 1 | def cleanup_control(): 2 | raise RuntimeError("cleanup control") 3 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_with_failing_init.py: -------------------------------------------------------------------------------- 1 | from chaoslib.types import Configuration, Experiment, Secrets, Settings 2 | 3 | 4 | def configure_control( 5 | experiment: Experiment, 6 | configuration: Configuration, 7 | secrets: Secrets, 8 | settings: Settings, 9 | ): 10 | raise RuntimeError("init control") 11 | 12 | 13 | def before_experiment_control(context: Experiment, **kwargs): 14 | context["should_never_been_called"] = True 15 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_with_interrupted_activity.py: -------------------------------------------------------------------------------- 1 | from chaoslib.exceptions import InterruptExecution 2 | from chaoslib.types import Activity 3 | 4 | 5 | def before_activity_control( 6 | context: Activity, target_activity_name: str, **kwargs 7 | ): 8 | if context.get("name") == target_activity_name: 9 | raise InterruptExecution("let's blow this up") 10 | -------------------------------------------------------------------------------- /tests/fixtures/controls/dummy_with_secrets.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from chaoslib.types import ( 4 | Activity, 5 | Configuration, 6 | Experiment, 7 | Hypothesis, 8 | Journal, 9 | Run, 10 | Secrets, 11 | Settings, 12 | ) 13 | 14 | 15 | def configure_control( 16 | experiment: Experiment, 17 | configuration: Configuration, 18 | secrets: Secrets, 19 | settings: Settings, 20 | ): 21 | experiment["configure_control_secrets"] = secrets 22 | 23 | 24 | def cleanup_control(): 25 | pass 26 | 27 | 28 | def before_experiment_control(context: Experiment, secrets: Secrets, **kwargs): 29 | context["before_experiment_control_secrets"] = secrets 30 | 31 | 32 | def after_experiment_control( 33 | context: Experiment, state: Journal, secrets: Secrets, **kwargs 34 | ): 35 | context["after_experiment_control_secrets"] = secrets 36 | 37 | 38 | def before_hypothesis_control( 39 | context: Hypothesis, experiment: Experiment, secrets: Secrets, **kwargs 40 | ): 41 | experiment["before_hypothesis_control_secrets"] = secrets 42 | 43 | 44 | def after_hypothesis_control( 45 | context: Hypothesis, 46 | experiment: Experiment, 47 | state: Dict[str, Any], 48 | secrets: Secrets, 49 | **kwargs, 50 | ): 51 | experiment["after_hypothesis_control_secrets"] = secrets 52 | 53 | 54 | def before_method_control(context: Experiment, secrets: Secrets, **kwargs): 55 | context["before_method_control_secrets"] = secrets 56 | 57 | 58 | def after_method_control( 59 | context: Experiment, state: List[Run], secrets: Secrets, **kwargs 60 | ): 61 | context["after_method_control_secrets"] = secrets 62 | 63 | 64 | def before_rollback_control(context: Experiment, secrets: Secrets, **kwargs): 65 | context["before_rollback_control_secrets"] = secrets 66 | 67 | 68 | def after_rollback_control( 69 | context: Experiment, state: List[Run], secrets: Secrets, **kwargs 70 | ): 71 | context["after_rollback_control_secrets"] = secrets 72 | 73 | 74 | def before_activity_control( 75 | context: Activity, experiment: Experiment, secrets: Secrets, **kwargs 76 | ): 77 | experiment["before_activity_control_secrets"] = secrets 78 | 79 | 80 | def after_activity_control( 81 | context: Activity, 82 | experiment: Experiment, 83 | state: Run, 84 | secrets: Secrets, 85 | **kwargs, 86 | ): 87 | experiment["after_activity_control_secrets"] = secrets 88 | -------------------------------------------------------------------------------- /tests/fixtures/controls/interrupter.py: -------------------------------------------------------------------------------- 1 | from chaoslib.exceptions import InterruptExecution 2 | from chaoslib.types import Activity 3 | 4 | 5 | def before_activity_control(context: Activity, **kwargs): 6 | raise InterruptExecution("let's blow this up") 7 | -------------------------------------------------------------------------------- /tests/fixtures/env_vars_issue252.json: -------------------------------------------------------------------------------- 1 | { 2 | "configuration": { 3 | "some_config_1": { 4 | "key": "PWD", 5 | "type": "env" 6 | }, 7 | "some_config_2": { 8 | "name": "some config 2", 9 | "type": "probe", 10 | "provider": { 11 | "type": "python", 12 | "module": "os.path", 13 | "func": "exists", 14 | "arguments": { 15 | "path": "${some_config_1}" 16 | } 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /tests/fixtures/fakeext.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from chaoslib.exceptions import ActivityFailed, InterruptExecution 4 | 5 | __all__ = [ 6 | "many_args", 7 | "many_args_with_rich_types", 8 | "no_args_docstring", 9 | "no_args", 10 | "one_arg", 11 | "one_untyped_arg", 12 | "one_arg_with_default", 13 | "one_untyped_arg_with_default", 14 | ] 15 | 16 | 17 | def no_args_docstring(): 18 | pass 19 | 20 | 21 | def no_args(): 22 | """ 23 | No arguments. 24 | """ 25 | pass 26 | 27 | 28 | def one_arg(message: str): 29 | """ 30 | One typed argument. 31 | """ 32 | pass 33 | 34 | 35 | def one_arg_with_default(message: str = "hello"): 36 | """ 37 | One typed argument with a default value. 38 | """ 39 | pass 40 | 41 | 42 | def one_untyped_arg(message): 43 | """ 44 | One untyped argument. 45 | """ 46 | pass 47 | 48 | 49 | def one_untyped_arg_with_default(message="hello"): 50 | """ 51 | One untyped argument with a default value. 52 | """ 53 | pass 54 | 55 | 56 | def many_args(message: str, colour: str = "blue"): 57 | """ 58 | Many arguments. 59 | """ 60 | pass 61 | 62 | 63 | class Whatever: 64 | pass 65 | 66 | 67 | def many_args_with_rich_types( 68 | message: str, 69 | recipients: List[str], 70 | colour: str = "blue", 71 | count: int = 1, 72 | logit: bool = False, 73 | other: Whatever = None, 74 | **kwargs, 75 | ) -> str: 76 | """ 77 | Many arguments with rich types. 78 | """ 79 | pass 80 | 81 | 82 | def do_nothing(): 83 | pass 84 | 85 | 86 | def echo_message(message: str) -> str: 87 | print(message) 88 | return message 89 | 90 | 91 | def force_failed_activity(): 92 | raise ActivityFailed() 93 | 94 | 95 | def force_interrupting_experiment(): 96 | raise InterruptExecution() 97 | -------------------------------------------------------------------------------- /tests/fixtures/interrupter.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from chaoslib.exit import exit_gracefully, exit_ungracefully 4 | 5 | 6 | def interrupt_gracefully_in(seconds: int = 1.5): 7 | time.sleep(seconds) 8 | exit_gracefully() 9 | 10 | 11 | def interrupt_ungracefully_in(seconds: int = 1.5): 12 | time.sleep(seconds) 13 | exit_ungracefully() 14 | -------------------------------------------------------------------------------- /tests/fixtures/interruptexperiment.py: -------------------------------------------------------------------------------- 1 | from chaoslib.exceptions import InterruptExecution 2 | 3 | 4 | def after_activity_control(**kwargs): 5 | raise InterruptExecution() 6 | -------------------------------------------------------------------------------- /tests/fixtures/keepempty.py: -------------------------------------------------------------------------------- 1 | # just keep this as-is 2 | 3 | 4 | def not_an_activity(): 5 | print("boom") 6 | -------------------------------------------------------------------------------- /tests/fixtures/longpythonfunc.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def pause(howlong: float = 3.0) -> None: 5 | time.sleep(howlong) 6 | 7 | 8 | def be_long(howlong: float = 3.0) -> int: 9 | end = time.time() + howlong 10 | 11 | i = 0 12 | while time.time() < end: 13 | i = i + 1 14 | time.sleep(0.1) 15 | 16 | return i 17 | -------------------------------------------------------------------------------- /tests/fixtures/notifier.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | __all__ = ["notify", "notify_other", "notify_broken"] 4 | 5 | logger = logging.getLogger("chaostoolkit") 6 | 7 | 8 | def notify(settings, event_payload): 9 | logger.debug("boom") 10 | 11 | 12 | def notify_other(settings, event_payload): 13 | logger.debug("doh") 14 | 15 | 16 | def notify_broken(settings, event_payload): 17 | raise Exception("An Exception") 18 | -------------------------------------------------------------------------------- /tests/fixtures/probes.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import sys 3 | from copy import deepcopy 4 | from typing import Any 5 | 6 | from chaoslib.exceptions import ActivityFailed 7 | 8 | EmptyProbe = {} 9 | 10 | MissingTypeProbe = {"name": "a name", "provider": {"module": "blah"}} 11 | 12 | UnknownTypeProbe = { 13 | "type": "whatever", 14 | "name": "a name", 15 | "provider": {"type": "python"}, 16 | } 17 | 18 | UnknownProviderTypeProbe = { 19 | "type": "probe", 20 | "name": "a name", 21 | "provider": {"type": "pizza"}, 22 | } 23 | 24 | MissingModuleProbe = { 25 | "type": "probe", 26 | "name": "a name", 27 | "provider": {"type": "python"}, 28 | } 29 | 30 | NotImportableModuleProbe = { 31 | "type": "probe", 32 | "name": "a name", 33 | "provider": {"type": "python", "module": "fake.module", "func": "myfunc"}, 34 | } 35 | 36 | MissingFunctionProbe = { 37 | "type": "probe", 38 | "provider": {"type": "python", "module": "os.path"}, 39 | "name": "a name", 40 | } 41 | 42 | MissingProcessPathProbe = { 43 | "type": "probe", 44 | "provider": {"type": "process"}, 45 | "name": "missing proc path", 46 | } 47 | 48 | ProcessPathDoesNotExistProbe = { 49 | "type": "probe", 50 | "provider": { 51 | "type": "process", 52 | "path": "somewhere/not/here", 53 | }, 54 | "name": "invalid proc path", 55 | } 56 | 57 | MissingHTTPUrlProbe = { 58 | "type": "probe", 59 | "provider": {"type": "http"}, 60 | "name": "A probe without url", 61 | } 62 | 63 | MissingFuncArgProbe = { 64 | "type": "probe", 65 | "name": "a name", 66 | "provider": { 67 | "type": "python", 68 | "module": "os.path", 69 | "func": "exists", 70 | "arguments": {}, 71 | }, 72 | } 73 | 74 | TooManyFuncArgsProbe = { 75 | "type": "probe", 76 | "name": "too-many-args-pause", 77 | "provider": { 78 | "type": "python", 79 | "module": "os.path", 80 | "func": "exists", 81 | "arguments": {"path": "/some/path", "should_not_be_here": "indeed not"}, 82 | }, 83 | } 84 | 85 | PythonModuleProbe = { 86 | "type": "probe", 87 | "name": "path-must-exists", 88 | "pauses": {"before": 0, "after": 0.1}, 89 | "provider": { 90 | "type": "python", 91 | "module": "os.path", 92 | "func": "exists", 93 | "arguments": { 94 | "path": os.path.abspath(__file__), 95 | }, 96 | "timeout": 30, 97 | }, 98 | } 99 | 100 | PythonModuleProbeWithLongPause = { 101 | "type": "probe", 102 | "name": "probe-with-long-pause", 103 | "pauses": {"before": 0, "after": 5}, 104 | "provider": { 105 | "type": "python", 106 | "module": "os.path", 107 | "func": "exists", 108 | "arguments": { 109 | "path": os.path.abspath(__file__), 110 | }, 111 | "timeout": 30, 112 | }, 113 | } 114 | 115 | BackgroundPythonModuleProbeWithLongPause = { 116 | "type": "probe", 117 | "name": "background-probe-with-long-pause", 118 | "background": True, 119 | "pauses": {"before": 0, "after": 5}, 120 | "provider": { 121 | "type": "python", 122 | "module": "os.path", 123 | "func": "exists", 124 | "arguments": { 125 | "path": os.path.abspath(__file__), 126 | }, 127 | "timeout": 30, 128 | }, 129 | } 130 | 131 | BackgroundPythonModuleProbeWithLongPauseBefore = deepcopy( 132 | BackgroundPythonModuleProbeWithLongPause 133 | ) 134 | BackgroundPythonModuleProbeWithLongPauseBefore["pauses"]["after"] = 0 135 | BackgroundPythonModuleProbeWithLongPauseBefore["pauses"]["before"] = 5 136 | 137 | PythonModuleProbeWithBoolTolerance = PythonModuleProbe.copy() 138 | # tolerance can be a scalar, a range or a mapping with lower/upper keys 139 | PythonModuleProbeWithBoolTolerance["tolerance"] = True 140 | PythonModuleProbeWithBoolTolerance["name"] = "boolean-probe" 141 | 142 | PythonModuleProbeWithExternalTolerance = PythonModuleProbe.copy() 143 | # tolerance can be a scalar, a range or a mapping with lower/upper keys 144 | PythonModuleProbeWithExternalTolerance["tolerance"] = PythonModuleProbe.copy() 145 | PythonModuleProbeWithExternalTolerance["name"] = "external-probe" 146 | 147 | PythonModuleProbeWithHTTPStatusTolerance = { 148 | "type": "probe", 149 | "name": "A dummy tolerance ready probe", 150 | "tolerance": [200, 301, 302], 151 | "provider": {"type": "http", "url": "http://example.com", "timeout": 30}, 152 | } 153 | 154 | PythonModuleProbeWithHTTPStatusToleranceDeviation = { 155 | "type": "probe", 156 | "name": "A dummy tolerance ready probe", 157 | "tolerance": [500], 158 | "provider": {"type": "http", "url": "http://example.com", "timeout": 30}, 159 | } 160 | 161 | PythonModuleProbeWithHTTPBodyTolerance = { 162 | "type": "probe", 163 | "name": "A dummy tolerance ready probe", 164 | "tolerance": {"type": "regex", "target": "body", "pattern": "[0-9]{2}"}, 165 | "provider": {"type": "http", "url": "http://example.com", "timeout": 30}, 166 | } 167 | 168 | PythonModuleProbeWithHTTPMaxRetries = { 169 | "type": "probe", 170 | "name": "A dummy tolerance ready probe", 171 | "tolerance": [200], 172 | "provider": { 173 | "type": "http", 174 | "url": "http://localhost:{}", 175 | "timeout": 10, 176 | "max_retries": 1, 177 | }, 178 | } 179 | 180 | PythonModuleProbeWithProcessStatusTolerance = { 181 | "type": "probe", 182 | "name": "A dummy tolerance ready probe", 183 | "tolerance": 0, 184 | "provider": { 185 | "type": "process", 186 | "path": sys.executable, 187 | "arguments": ["-V"], 188 | "timeout": 1, 189 | }, 190 | } 191 | 192 | PythonModuleProbeWithProcessFailedStatusTolerance = { 193 | "type": "probe", 194 | "name": "A dummy tolerance ready probe", 195 | "tolerance": 2, 196 | "provider": { 197 | "type": "process", 198 | "path": sys.executable, 199 | "arguments": ["--burp"], 200 | "timeout": 1, 201 | }, 202 | } 203 | 204 | PythonModuleProbeWithProcesStdoutTolerance = { 205 | "type": "probe", 206 | "name": "A dummy tolerance ready probe", 207 | "tolerance": { 208 | "type": "regex", 209 | "target": "stdout", 210 | "pattern": r"Python [0-9]\.[0-9]\.[0-9]", 211 | }, 212 | "provider": { 213 | "type": "process", 214 | "path": sys.executable, 215 | "arguments": ["-V"], 216 | "timeout": 1, 217 | }, 218 | } 219 | 220 | ProcProbe = { 221 | "type": "probe", 222 | "name": "This probe is a process probe", 223 | "pauses": {"before": 0, "after": 0.1}, 224 | "provider": { 225 | "type": "process", 226 | "path": sys.executable, 227 | "arguments": ["-V"], 228 | "timeout": 1, 229 | }, 230 | } 231 | 232 | DeprecatedProcArgumentsProbe = { 233 | "type": "probe", 234 | "name": "This probe is a process probe", 235 | "pauses": {"before": 0, "after": 0.1}, 236 | "provider": { 237 | "type": "process", 238 | "path": sys.executable, 239 | "arguments": {"-V": None}, 240 | "timeout": 1, 241 | }, 242 | } 243 | 244 | ProcEchoArrayProbe = { 245 | "type": "probe", 246 | "name": ( 247 | "This probe is a process probe that simply echoes its arguments passed" 248 | " as an array" 249 | ), 250 | "pauses": {"before": 0, "after": 0.1}, 251 | "provider": { 252 | "type": "process", 253 | "path": sys.executable, 254 | "arguments": [ 255 | "-c", 256 | "import sys; print(sys.argv)", 257 | "--empty", 258 | "--number", 259 | 1, 260 | "--string", 261 | "with spaces", 262 | "--string", 263 | "a second string with the same option", 264 | ], 265 | "timeout": 1, 266 | }, 267 | } 268 | 269 | ProcEchoStrProbe = { 270 | "type": "probe", 271 | "name": ( 272 | "This probe is a process probe that simply echoes its arguments passed" 273 | " as a string" 274 | ), 275 | "pauses": {"before": 0, "after": 0.1}, 276 | "provider": { 277 | "type": "process", 278 | "path": sys.executable, 279 | "arguments": ( 280 | "-c 'import sys; print(sys.argv)' --empty --number 1 --string 'with spaces'" 281 | " --string 'a second string with the same option'" 282 | ), 283 | "timeout": 1, 284 | }, 285 | } 286 | 287 | HTTPProbe = { 288 | "type": "probe", 289 | "name": "This probe is a HTTP probe", 290 | "provider": { 291 | "type": "http", 292 | "url": "http://example.com", 293 | "method": "post", 294 | "arguments": { 295 | "q": "chaostoolkit", 296 | }, 297 | "timeout": 30, 298 | }, 299 | "pauses": {"before": 0, "after": 0.1}, 300 | } 301 | 302 | BackgroundPythonModuleProbe = { 303 | "type": "probe", 304 | "name": "a-background-probe", 305 | "background": True, 306 | "provider": { 307 | "type": "python", 308 | "module": "os.path", 309 | "func": "exists", 310 | "arguments": { 311 | "path": __file__, 312 | }, 313 | }, 314 | } 315 | 316 | 317 | def must_be_in_range(a: int, b: int, value: Any = None) -> bool: 318 | if not (a < int(value.get("body")) < b): 319 | raise ActivityFailed("body is not in expected range") 320 | else: 321 | return True 322 | 323 | 324 | FailProbe = { 325 | "name": "a name", 326 | "type": "probe", 327 | "tolerance": True, 328 | "provider": { 329 | "type": "python", 330 | "module": "fixtures.fakeext", 331 | "func": "force_failed_activity", 332 | }, 333 | } 334 | 335 | 336 | GenerateSecretTokenProbe = { 337 | "type": "probe", 338 | "name": "generate-token", 339 | "provider": { 340 | "type": "python", 341 | "module": "random", 342 | "func": "choice", 343 | "arguments": {"seq": ["RED", "BLUE", "YELLOW"]}, 344 | }, 345 | } 346 | 347 | 348 | ReadSecretTokenProbe = { 349 | "type": "action", 350 | "name": "use-token", 351 | "provider": { 352 | "type": "python", 353 | "module": "pprint", 354 | "func": "pformat", 355 | "arguments": {"object": "${my_token}"}, 356 | }, 357 | } 358 | 359 | 360 | ReadSecretTokenFromSecretsProbe = { 361 | "type": "action", 362 | "name": "use-token", 363 | "provider": { 364 | "type": "python", 365 | "module": "pprint", 366 | "func": "pformat", 367 | "secrets": ["mytokens"], 368 | "arguments": {"object": "${my_token}"}, 369 | }, 370 | } 371 | -------------------------------------------------------------------------------- /tests/fixtures/run_handlers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from chaoslib.run import RunEventHandler 4 | from chaoslib.types import ( 5 | Activity, 6 | Configuration, 7 | Experiment, 8 | Journal, 9 | Run, 10 | Schedule, 11 | Secrets, 12 | Settings, 13 | ) 14 | 15 | 16 | class FullRunEventHandler(RunEventHandler): 17 | def __init__(self): 18 | self.calls = [] 19 | 20 | def started(self, experiment: Experiment, journal: Journal) -> None: 21 | self.calls.append("started") 22 | 23 | def running( 24 | self, 25 | experiment: Experiment, 26 | journal: Journal, 27 | configuration: Configuration, 28 | secrets: Secrets, 29 | schedule: Schedule, 30 | settings: Settings, 31 | ) -> None: 32 | self.calls.append("running") 33 | 34 | def finish(self, journal: Journal) -> None: 35 | self.calls.append("finish") 36 | 37 | def interrupted(self, experiment: Experiment, journal: Journal) -> None: 38 | self.calls.append("interrupted") 39 | 40 | def signal_exit(self) -> None: 41 | self.calls.append("signal_exit") 42 | 43 | def start_continuous_hypothesis(self, frequency: int) -> None: 44 | self.calls.append("start_continuous_hypothesis") 45 | 46 | def continuous_hypothesis_iteration( 47 | self, iteration_index: int, state: Any 48 | ) -> None: 49 | self.calls.append("continuous_hypothesis_iteration") 50 | 51 | def continuous_hypothesis_completed( 52 | self, 53 | experiment: Experiment, 54 | journal: Journal, 55 | exception: Exception = None, 56 | ) -> None: 57 | self.calls.append("continuous_hypothesis_completed") 58 | 59 | def start_method(self, experiment: Experiment) -> None: 60 | self.calls.append("start_method") 61 | 62 | def method_completed(self, experiment: Experiment, state: Any) -> None: 63 | self.calls.append("method_completed") 64 | 65 | def start_rollbacks(self, experiment: Experiment) -> None: 66 | self.calls.append("start_rollbacks") 67 | 68 | def rollbacks_completed(self, experiment: Experiment, state: Any) -> None: 69 | self.calls.append("rollbacks_completed") 70 | 71 | def start_hypothesis_before(self, experiment: Experiment) -> None: 72 | self.calls.append("start_hypothesis_before") 73 | 74 | def hypothesis_before_completed( 75 | self, experiment: Experiment, state: Dict[str, Any], journal: Journal 76 | ) -> None: 77 | self.calls.append("hypothesis_before_completed") 78 | 79 | def start_hypothesis_after(self, experiment: Experiment) -> None: 80 | self.calls.append("start_hypothesis_after") 81 | 82 | def hypothesis_after_completed( 83 | self, experiment: Experiment, state: Dict[str, Any], journal: Journal 84 | ) -> None: 85 | self.calls.append("hypothesis_after_completed") 86 | 87 | def start_cooldown(self, duration: int) -> None: 88 | self.calls.append("start_cooldown") 89 | 90 | def cooldown_completed(self) -> None: 91 | self.calls.append("cooldown_completed") 92 | 93 | def start_activity(self, activity: Activity) -> None: 94 | self.calls.append("start_activity") 95 | 96 | def activity_completed(self, activity: Activity, run: Run) -> None: 97 | self.calls.append("activity_completed") 98 | 99 | 100 | class FullExceptionRunEventHandler(RunEventHandler): 101 | def __init__(self): 102 | self.calls = [] 103 | 104 | def started(self, experiment: Experiment, journal: Journal) -> None: 105 | raise Exception() 106 | 107 | def finish(self, journal: Journal) -> None: 108 | raise Exception() 109 | 110 | def interrupted(self, experiment: Experiment, journal: Journal) -> None: 111 | raise Exception() 112 | 113 | def signal_exit(self) -> None: 114 | raise Exception() 115 | 116 | def start_continuous_hypothesis(self, frequency: int) -> None: 117 | raise Exception() 118 | 119 | def continuous_hypothesis_iteration( 120 | self, iteration_index: int, state: Any 121 | ) -> None: 122 | raise Exception() 123 | 124 | def continuous_hypothesis_completed(self) -> None: 125 | raise Exception() 126 | 127 | def start_rollbacks(self, experiment: Experiment) -> None: 128 | raise Exception() 129 | 130 | def rollbacks_completed(self, experiment: Experiment, state: Any) -> None: 131 | raise Exception() 132 | 133 | def start_hypothesis_before(self, experiment: Experiment) -> None: 134 | raise Exception() 135 | 136 | def hypothesis_before_completed( 137 | self, experiment: Experiment, state: Dict[str, Any], journal: Journal 138 | ) -> None: 139 | raise Exception() 140 | 141 | def start_hypothesis_after(self, experiment: Experiment) -> None: 142 | raise Exception() 143 | 144 | def hypothesis_after_completed( 145 | self, experiment: Experiment, state: Dict[str, Any], journal: Journal 146 | ) -> None: 147 | raise Exception() 148 | 149 | def start_method(self, iteration_index: int = 0) -> None: 150 | raise Exception() 151 | 152 | def method_completed(self, state: Any, iteration_index: int = 0) -> None: 153 | raise Exception() 154 | 155 | def start_cooldown(self, duration: int) -> None: 156 | raise Exception() 157 | 158 | def cooldown_completed(self) -> None: 159 | raise Exception() 160 | 161 | def start_activity(self, activity: Activity) -> None: 162 | raise Exception() 163 | 164 | def activity_completed(self, activity: Activity, run: Run) -> None: 165 | raise Exception() 166 | -------------------------------------------------------------------------------- /tests/fixtures/settings.yaml: -------------------------------------------------------------------------------- 1 | notifications: 2 | - 3 | type: plugin 4 | module: 5 | function: 6 | - 7 | type: http 8 | url: https://slack 9 | - 10 | type: http 11 | url: https://elsewhere 12 | headers: 13 | - auth: "Bearer token" 14 | -------------------------------------------------------------------------------- /tests/fixtures/unsafe-settings.yaml: -------------------------------------------------------------------------------- 1 | 2 | !!python/object/apply:os.system 3 | args: ['Hello shell!'] 4 | 5 | -------------------------------------------------------------------------------- /tests/test_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fixtures import actions 3 | 4 | from chaoslib.activity import ensure_activity_is_valid 5 | from chaoslib.exceptions import InvalidActivity 6 | 7 | 8 | def test_empty_action_is_invalid(): 9 | with pytest.raises(InvalidActivity) as exc: 10 | ensure_activity_is_valid(actions.EmptyAction) 11 | assert "empty activity is no activity" in str(exc.value) 12 | -------------------------------------------------------------------------------- /tests/test_deprecation.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from unittest.mock import patch 3 | 4 | from fixtures import experiments 5 | 6 | from chaoslib import deprecation, experiment 7 | from chaoslib.deprecation import ( 8 | DeprecatedDictArgsMessage, 9 | DeprecatedVaultMissingPathMessage, 10 | warn_about_deprecated_features, 11 | ) 12 | from chaoslib.experiment import ( 13 | apply_activities, 14 | apply_rollbacks, 15 | initialize_run_journal, 16 | ) 17 | 18 | 19 | def test_run_dict_arguments_has_been_deprecated_in_favor_of_list(): 20 | warn_counts = 0 21 | with warnings.catch_warnings(record=True) as w: 22 | warnings.simplefilter("module") 23 | warn_about_deprecated_features( 24 | experiments.ExperimentWithDeprecatedProcArgsProbe 25 | ) 26 | for warning in w: 27 | if ( 28 | issubclass(warning.category, DeprecationWarning) 29 | and warning.filename == deprecation.__file__ 30 | ): 31 | assert DeprecatedDictArgsMessage in str(warning.message) 32 | warn_counts = warn_counts + 1 33 | 34 | assert warn_counts == 1 35 | 36 | 37 | def test_vault_secrets_require_path(): 38 | warn_counts = 0 39 | with warnings.catch_warnings(record=True) as w: 40 | warnings.simplefilter("module") 41 | warn_about_deprecated_features( 42 | experiments.ExperimentWithDeprecatedVaultPayload 43 | ) 44 | for warning in w: 45 | if ( 46 | issubclass(warning.category, DeprecationWarning) 47 | and warning.filename == deprecation.__file__ 48 | ): 49 | assert DeprecatedVaultMissingPathMessage in str(warning.message) 50 | warn_counts = warn_counts + 1 51 | 52 | assert warn_counts == 1 53 | 54 | 55 | def test_initialize_run_journal_has_moved(): 56 | with warnings.catch_warnings(record=True) as w: 57 | warnings.simplefilter("module") 58 | with patch("chaoslib.experiment.init_journal"): 59 | initialize_run_journal(None) 60 | assert len(w) == 1 61 | assert w[0].filename == experiment.__file__ 62 | assert "'initialize_run_journal'" in str(w[0].message) 63 | 64 | 65 | def test_apply_activities_has_moved(): 66 | with warnings.catch_warnings(record=True) as w: 67 | warnings.simplefilter("module") 68 | with patch("chaoslib.experiment.apply_act"): 69 | apply_activities(None, None, None, None, None, None) 70 | assert len(w) == 1 71 | assert w[0].filename == experiment.__file__ 72 | assert "'apply_activities'" in str(w[0].message) 73 | 74 | 75 | def test_apply_rollbacks_has_moved(): 76 | with warnings.catch_warnings(record=True) as w: 77 | warnings.simplefilter("module") 78 | with patch("chaoslib.experiment.apply_roll"): 79 | apply_rollbacks(None, None, None, None, None) 80 | assert len(w) == 1 81 | assert w[0].filename == experiment.__file__ 82 | assert "'apply_rollbacks'" in str(w[0].message) 83 | -------------------------------------------------------------------------------- /tests/test_discover.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from chaoslib.discovery.discover import discover_activities 4 | from chaoslib.exceptions import DiscoveryFailed 5 | 6 | 7 | def test_fail_discovery_when_module_cannot_be_loaded(): 8 | with pytest.raises(DiscoveryFailed) as exc: 9 | discover_activities("fixtures.burp", "probe") 10 | assert "could not import extension module" in str(exc.value) 11 | 12 | 13 | def test_do_not_fail_when_extension_mod_has_not_all(): 14 | activities = discover_activities("fixtures.keepempty", "probe") 15 | assert len(activities) == 0 16 | 17 | 18 | def test_discover_all_activities(): 19 | mod = "fixtures.fakeext" 20 | activities = discover_activities(mod, "probe") 21 | assert len(activities) == 8 22 | 23 | activities = iter(activities) 24 | 25 | activity = next(activities) 26 | assert activity["name"] == "many_args" 27 | assert activity["type"] == "probe" 28 | assert activity["mod"] == mod 29 | assert activity["doc"] == "Many arguments." 30 | assert activity["arguments"] == [ 31 | {"name": "message", "type": "string"}, 32 | {"name": "colour", "default": "blue", "type": "string"}, 33 | ] 34 | 35 | activity = next(activities) 36 | assert activity["name"] == "many_args_with_rich_types" 37 | assert activity["type"] == "probe" 38 | assert activity["mod"] == mod 39 | assert activity["doc"] == "Many arguments with rich types." 40 | assert activity["arguments"] == [ 41 | {"name": "message", "type": "string"}, 42 | {"name": "recipients", "type": "list"}, 43 | {"name": "colour", "default": "blue", "type": "string"}, 44 | {"name": "count", "default": 1, "type": "integer"}, 45 | {"name": "logit", "default": False, "type": "boolean"}, 46 | {"name": "other", "default": None, "type": "object"}, 47 | ] 48 | 49 | activity = next(activities) 50 | assert activity["name"] == "no_args" 51 | assert activity["type"] == "probe" 52 | assert activity["mod"] == mod 53 | assert activity["doc"] == "No arguments." 54 | assert activity["arguments"] == [] 55 | 56 | activity = next(activities) 57 | assert activity["name"] == "no_args_docstring" 58 | assert activity["type"] == "probe" 59 | assert activity["mod"] == mod 60 | assert activity["doc"] is None 61 | assert activity["arguments"] == [] 62 | 63 | activity = next(activities) 64 | assert activity["name"] == "one_arg" 65 | assert activity["type"] == "probe" 66 | assert activity["mod"] == mod 67 | assert activity["doc"] == "One typed argument." 68 | assert activity["arguments"] == [{"name": "message", "type": "string"}] 69 | 70 | activity = next(activities) 71 | assert activity["name"] == "one_arg_with_default" 72 | assert activity["type"] == "probe" 73 | assert activity["mod"] == mod 74 | assert activity["doc"] == "One typed argument with a default value." 75 | assert activity["arguments"] == [ 76 | {"name": "message", "default": "hello", "type": "string"} 77 | ] 78 | 79 | activity = next(activities) 80 | assert activity["name"] == "one_untyped_arg" 81 | assert activity["type"] == "probe" 82 | assert activity["mod"] == mod 83 | assert activity["doc"] == "One untyped argument." 84 | assert activity["arguments"] == [{"name": "message"}] 85 | 86 | activity = next(activities) 87 | assert activity["name"] == "one_untyped_arg_with_default" 88 | assert activity["type"] == "probe" 89 | assert activity["mod"] == mod 90 | assert activity["doc"] == "One untyped argument with a default value." 91 | assert activity["arguments"] == [{"name": "message", "default": "hello"}] 92 | -------------------------------------------------------------------------------- /tests/test_exit.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | import time 4 | from copy import deepcopy 5 | from wsgiref.simple_server import WSGIRequestHandler, WSGIServer 6 | 7 | import pytest 8 | from fixtures import experiments 9 | 10 | from chaoslib.exit import exit_gracefully, exit_ungracefully 11 | from chaoslib.run import Runner 12 | from chaoslib.types import Strategy 13 | 14 | pytestmark = pytest.mark.skipif(os.getenv("CI") is not None, reason="Skip CI") 15 | 16 | 17 | def run_http_server_in_background(): 18 | def slow_app(environ, start_response): 19 | time.sleep(5) 20 | status = "200 OK" 21 | headers = [("Content-type", "text/plain; charset=utf-8")] 22 | start_response(status, headers) 23 | return [b"Hello World"] 24 | 25 | def make_server(host, port, app): 26 | server = WSGIServer((host, port), WSGIRequestHandler) 27 | server.set_app(app) 28 | return server 29 | 30 | httpd = make_server("", 8700, slow_app) 31 | httpd.handle_request() 32 | 33 | 34 | def test_play_rollbacks_on_graceful_exit_with_http_action(): 35 | server = threading.Thread(target=run_http_server_in_background) 36 | server.start() 37 | 38 | x = deepcopy(experiments.ExperimentGracefulExitLongHTTPCall) 39 | with Runner(Strategy.DEFAULT) as runner: 40 | journal = runner.run( 41 | x, settings={"runtime": {"rollbacks": {"strategy": "always"}}} 42 | ) 43 | 44 | assert journal["status"] == "interrupted" 45 | assert len(journal["rollbacks"]) == 1 46 | 47 | server.join() 48 | 49 | 50 | def test_do_not_play_rollbacks_on_graceful_exit_with_http_action(): 51 | server = threading.Thread(target=run_http_server_in_background) 52 | server.start() 53 | 54 | x = deepcopy(experiments.ExperimentUngracefulExitLongHTTPCall) 55 | with Runner(Strategy.DEFAULT) as runner: 56 | journal = runner.run( 57 | x, settings={"runtime": {"rollbacks": {"strategy": "always"}}} 58 | ) 59 | 60 | assert journal["status"] == "interrupted" 61 | assert len(journal["rollbacks"]) == 0 62 | 63 | server.join() 64 | 65 | 66 | def test_play_rollbacks_on_graceful_exit_with_process_action(): 67 | x = deepcopy(experiments.ExperimentGracefulExitLongProcessCall) 68 | with Runner(Strategy.DEFAULT) as runner: 69 | journal = runner.run( 70 | x, settings={"runtime": {"rollbacks": {"strategy": "always"}}} 71 | ) 72 | 73 | assert journal["status"] == "interrupted" 74 | assert len(journal["rollbacks"]) == 1 75 | 76 | 77 | def test_do_not_play_rollbacks_on_graceful_exit_with_process_action(): 78 | x = deepcopy(experiments.ExperimentUngracefulExitLongProcessCall) 79 | with Runner(Strategy.DEFAULT) as runner: 80 | journal = runner.run( 81 | x, settings={"runtime": {"rollbacks": {"strategy": "always"}}} 82 | ) 83 | 84 | assert journal["status"] == "interrupted" 85 | assert len(journal["rollbacks"]) == 0 86 | 87 | 88 | def test_play_rollbacks_on_graceful_exit_with_python_action(): 89 | x = deepcopy(experiments.ExperimentGracefulExitLongPythonCall) 90 | with Runner(Strategy.DEFAULT) as runner: 91 | journal = runner.run( 92 | x, settings={"runtime": {"rollbacks": {"strategy": "always"}}} 93 | ) 94 | 95 | assert journal["status"] == "interrupted" 96 | assert len(journal["rollbacks"]) == 1 97 | 98 | 99 | def test_do_not_play_rollbacks_on_graceful_exit_with_python_action(): 100 | server = threading.Thread(target=run_http_server_in_background) 101 | server.start() 102 | 103 | x = deepcopy(experiments.ExperimentUngracefulExitLongHTTPCall) 104 | with Runner(Strategy.DEFAULT) as runner: 105 | journal = runner.run( 106 | x, settings={"runtime": {"rollbacks": {"strategy": "always"}}} 107 | ) 108 | 109 | assert journal["status"] == "interrupted" 110 | assert len(journal["rollbacks"]) == 0 111 | 112 | server.join() 113 | 114 | 115 | def test_wait_for_background_activity_on_graceful_exit(): 116 | server = threading.Thread(target=run_http_server_in_background) 117 | server.start() 118 | 119 | x = deepcopy(experiments.ExperimentGracefulExitLongHTTPCall) 120 | with Runner(Strategy.DEFAULT) as runner: 121 | journal = runner.run(x) 122 | 123 | assert journal["status"] == "interrupted" 124 | assert 3.0 < journal["run"][0]["duration"] < 3.2 125 | 126 | server.join() 127 | 128 | 129 | def test_do_not_wait_for_background_activity_on_ungraceful_exit(): 130 | def _exit_soon(): 131 | time.sleep(1.5) 132 | exit_ungracefully() 133 | 134 | t = threading.Thread(target=_exit_soon) 135 | 136 | x = deepcopy(experiments.SimpleExperimentWithBackgroundActivity) 137 | with Runner(Strategy.DEFAULT) as runner: 138 | t.start() 139 | journal = runner.run(x) 140 | assert journal["status"] == "interrupted" 141 | assert journal["run"][0]["status"] == "failed" 142 | assert "ExperimentExitedException" in journal["run"][0]["exception"][-1] 143 | 144 | 145 | def test_wait_for_background_activity_to_finish_on_graceful_exit(): 146 | def _exit_soon(): 147 | time.sleep(1.5) 148 | exit_gracefully() 149 | 150 | t = threading.Thread(target=_exit_soon) 151 | 152 | x = deepcopy(experiments.SimpleExperimentWithBackgroundActivity) 153 | with Runner(Strategy.DEFAULT) as runner: 154 | t.start() 155 | journal = runner.run(x) 156 | assert journal["status"] == "interrupted" 157 | assert journal["run"][0]["status"] == "succeeded" 158 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fixtures import experiments 3 | 4 | from chaoslib.exceptions import InvalidExperiment 5 | from chaoslib.extension import ( 6 | get_extension, 7 | merge_extension, 8 | remove_extension, 9 | set_extension, 10 | validate_extensions, 11 | ) 12 | 13 | 14 | def test_extensions_must_have_name(): 15 | with pytest.raises(InvalidExperiment): 16 | exp = experiments.Experiment.copy() 17 | set_extension(exp, {"somekey": "blah"}) 18 | validate_extensions(exp) 19 | 20 | 21 | def test_get_extension_returns_nothing_when_not_extensions_block(): 22 | assert get_extension(experiments.Experiment, "myext") is None 23 | 24 | 25 | def test_get_extension_returns_nothing_when_missing(): 26 | ext = experiments.Experiment.copy() 27 | set_extension(ext, {"name": "myotherext", "somekey": "blah"}) 28 | assert get_extension(ext, "myext") is None 29 | 30 | 31 | def test_get_extension(): 32 | exp = experiments.Experiment.copy() 33 | set_extension(exp, {"name": "myext", "somekey": "blah"}) 34 | 35 | ext = get_extension(exp, "myext") 36 | assert ext is not None 37 | assert ext["somekey"] == "blah" 38 | 39 | 40 | def test_remove_extension(): 41 | exp = experiments.Experiment.copy() 42 | set_extension(exp, {"name": "myext", "somekey": "blah"}) 43 | 44 | assert get_extension(exp, "myext") is not None 45 | remove_extension(exp, "myext") 46 | assert get_extension(exp, "myext") is None 47 | 48 | 49 | def test_merge_extension(): 50 | exp = experiments.Experiment.copy() 51 | set_extension(exp, {"name": "myext", "somekey": "blah"}) 52 | 53 | ext = get_extension(exp, "myext") 54 | assert ext is not None 55 | assert ext["somekey"] == "blah" 56 | 57 | merge_extension( 58 | exp, {"name": "myext", "somekey": "burp", "otherkey": "oneday"} 59 | ) 60 | 61 | ext = get_extension(exp, "myext") 62 | assert ext is not None 63 | assert ext["somekey"] == "burp" 64 | assert ext["otherkey"] == "oneday" 65 | -------------------------------------------------------------------------------- /tests/test_hash.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | 4 | import pytest 5 | 6 | from chaoslib import experiment_hash 7 | 8 | 9 | def test_with_default(): 10 | assert ( 11 | experiment_hash({}) 12 | == hashlib.blake2b( 13 | json.dumps({}).encode("utf-8"), digest_size=12 14 | ).hexdigest() 15 | ) 16 | 17 | 18 | def test_specific_algo(): 19 | assert ( 20 | experiment_hash({}, hash_algo="sha256") 21 | == hashlib.sha256(json.dumps({}).encode("utf-8")).hexdigest() 22 | ) 23 | 24 | 25 | def test_unavailable_algo(): 26 | with pytest.raises(ValueError): 27 | experiment_hash({}, hash_algo="md78") 28 | -------------------------------------------------------------------------------- /tests/test_info.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from unittest.mock import patch 3 | 4 | try: 5 | from importlib.metadata import Distribution 6 | except ImportError: 7 | from importlib_metadata import Distribution 8 | 9 | from chaoslib.info import list_extensions 10 | 11 | PGK_META = """Metadata-Version: 2.1 12 | Name: chaostoolkit-some-stuff 13 | Version: 0.1.0 14 | Summary: Chaos Toolkit some package 15 | Home-page: http://chaostoolkit.org 16 | Author: chaostoolkit Team 17 | Author-email: contact@chaostoolkit.org 18 | License: Apache License 2.0 19 | """ 20 | 21 | 22 | class InMemoryDistribution(Distribution): 23 | def __init__(self, metadata, *args, **kwargs): 24 | Distribution.__init__(self, *args, **kwargs) 25 | self._data = metadata 26 | 27 | def read_text(self, filename): 28 | return self._data 29 | 30 | def locate_file(self, path): 31 | pass 32 | 33 | 34 | @patch("chaoslib.info.importlib_metadata.distributions") 35 | def test_list_none_when_none_installed(distros: List[Distribution]): 36 | distros.return_value = [] 37 | extensions = list_extensions() 38 | assert extensions == [] 39 | 40 | 41 | @patch("chaoslib.info.importlib_metadata.distributions") 42 | def test_list_one_installed(distros: List[Distribution]): 43 | distros.return_value = [InMemoryDistribution(PGK_META)] 44 | 45 | extensions = list_extensions() 46 | assert len(extensions) == 1 47 | 48 | ext = extensions[0] 49 | assert ext.name == "chaostoolkit-some-stuff" 50 | assert ext.version == "0.1.0" 51 | 52 | 53 | @patch("chaoslib.info.importlib_metadata.distributions") 54 | def test_list_excludes_ctklib(distros: List[Distribution]): 55 | metadata = """Name: chaostoolkit-lib""" 56 | distros.return_value = [InMemoryDistribution(metadata)] 57 | 58 | extensions = list_extensions() 59 | assert len(extensions) == 0 60 | 61 | 62 | @patch("chaoslib.info.importlib_metadata.distributions") 63 | def test_list_skip_duplicates(distros: List[Distribution]): 64 | distros.return_value = [ 65 | InMemoryDistribution(PGK_META), 66 | InMemoryDistribution(PGK_META), 67 | ] 68 | 69 | extensions = list_extensions() 70 | assert len(extensions) == 1 71 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | 5 | import pytest 6 | import requests 7 | import requests_mock 8 | from fixtures import experiments 9 | 10 | from chaoslib.exceptions import InvalidExperiment, InvalidSource 11 | from chaoslib.loader import load_experiment, parse_experiment_from_file 12 | from chaoslib.types import Settings 13 | 14 | 15 | def test_load_from_file(generic_experiment: str): 16 | try: 17 | load_experiment(generic_experiment) 18 | except InvalidSource as x: 19 | pytest.fail(str(x)) 20 | 21 | 22 | def test_load_invalid_filepath(generic_experiment: str): 23 | with pytest.raises(InvalidSource) as x: 24 | load_experiment("/tmp/xyuzye.txt") 25 | assert 'Path "/tmp/xyuzye.txt" does not exist.' in str(x.value) 26 | 27 | 28 | def test_load_from_http_without_auth(generic_experiment: str): 29 | with requests_mock.mock() as m: 30 | m.get( 31 | "http://example.com/experiment.json", 32 | status_code=200, 33 | headers={"Content-Type": "application/json"}, 34 | json=json.dumps(generic_experiment), 35 | ) 36 | try: 37 | load_experiment("http://example.com/experiment.json") 38 | except InvalidSource as x: 39 | pytest.fail(str(x)) 40 | 41 | 42 | def test_load_from_http_with_missing_auth(generic_experiment: str): 43 | with requests_mock.mock() as m: 44 | m.get("http://example.com/experiment.json", status_code=401) 45 | with pytest.raises(InvalidSource): 46 | load_experiment("http://example.com/experiment.json") 47 | 48 | 49 | def test_load_from_http_with_auth(settings: Settings, generic_experiment: str): 50 | with requests_mock.mock() as m: 51 | settings["auths"] = {"example.com": {"type": "bearer", "value": "XYZ"}} 52 | m.get( 53 | "http://example.com/experiment.json", 54 | status_code=200, 55 | request_headers={ 56 | "Authorization": "bearer XYZ", 57 | "Accept": "application/json, application/x-yaml", 58 | }, 59 | headers={"Content-Type": "application/json"}, 60 | json=json.dumps(generic_experiment), 61 | ) 62 | try: 63 | load_experiment("http://example.com/experiment.json", settings) 64 | except InvalidSource as x: 65 | pytest.fail(str(x)) 66 | 67 | 68 | def test_yaml_safe_load_from_file(): 69 | with tempfile.NamedTemporaryFile(suffix=".yaml") as f: 70 | f.write(experiments.UnsafeYamlExperiment.encode("utf-8")) 71 | f.seek(0) 72 | 73 | with pytest.raises(InvalidSource): 74 | parse_experiment_from_file(f.name) 75 | 76 | 77 | def test_yaml_safe_load_from_http(): 78 | with requests_mock.mock() as m: 79 | m.get( 80 | "http://example.com/experiment.yaml", 81 | status_code=200, 82 | headers={"Content-Type": "application/x-yaml"}, 83 | text=experiments.UnsafeYamlExperiment, 84 | ) 85 | with pytest.raises(InvalidSource): 86 | load_experiment("http://example.com/experiment.yaml") 87 | 88 | 89 | def test_can_load_yaml_from_plain_text_http(): 90 | with requests_mock.mock() as m: 91 | m.get( 92 | "http://example.com/experiment.yaml", 93 | status_code=200, 94 | headers={"Content-Type": "text/plain; charset=utf-8"}, 95 | text=experiments.YamlExperiment, 96 | ) 97 | try: 98 | load_experiment("http://example.com/experiment.yaml") 99 | except InvalidExperiment as x: 100 | pytest.fail(str(x)) 101 | 102 | 103 | def test_can_load_json_from_plain_text_http(generic_experiment: str): 104 | with requests_mock.mock() as m: 105 | m.get( 106 | "http://example.com/experiment.json", 107 | status_code=200, 108 | headers={"Content-Type": "text/plain; charset=utf-8"}, 109 | text=json.dumps(generic_experiment), 110 | ) 111 | try: 112 | load_experiment("http://example.com/experiment.json") 113 | except InvalidExperiment as x: 114 | pytest.fail(str(x)) 115 | 116 | 117 | def test_http_loads_fails_when_known_type(): 118 | with requests_mock.mock() as m: 119 | m.get( 120 | "http://example.com/experiment.yaml", 121 | status_code=200, 122 | headers={"Content-Type": "text/css"}, 123 | text="body {}", 124 | ) 125 | with pytest.raises(InvalidExperiment): 126 | load_experiment("http://example.com/experiment.yaml") 127 | 128 | 129 | def test_https_no_verification(): 130 | with requests_mock.mock() as m: 131 | m.get( 132 | "https://example.com/experiment.yaml", 133 | status_code=200, 134 | headers={"Content-Type": "text/css"}, 135 | text="body {}", 136 | ) 137 | with pytest.raises(InvalidExperiment): 138 | load_experiment( 139 | "https://example.com/experiment.yaml", verify_tls=False 140 | ) 141 | 142 | 143 | def test_https_with_verification(): 144 | with requests_mock.mock() as m: 145 | m.get( 146 | "https://example.com/experiment.yaml", 147 | exc=requests.exceptions.SSLError, 148 | ) 149 | with pytest.raises(requests.exceptions.SSLError): 150 | load_experiment( 151 | "https://example.com/experiment.yaml", verify_tls=True 152 | ) 153 | 154 | 155 | def test_load_from_http_with_auth_from_env( 156 | settings: Settings, generic_experiment: str 157 | ): 158 | try: 159 | os.environ["CHAOSTOOLKIT_LOADER_AUTH_BEARER_TOKEN"] = "XYZ" 160 | with requests_mock.mock() as m: 161 | m.get( 162 | "http://example.com/experiment.json", 163 | status_code=200, 164 | request_headers={ 165 | "Authorization": "bearer XYZ", 166 | "Accept": "application/json, application/x-yaml", 167 | }, 168 | headers={"Content-Type": "application/json"}, 169 | json=json.dumps(generic_experiment), 170 | ) 171 | try: 172 | load_experiment("http://example.com/experiment.json") 173 | except InvalidSource as x: 174 | pytest.fail(str(x)) 175 | finally: 176 | os.environ.pop("CHAOSTOOLKIT_LOADER_AUTH_BEARER_TOKEN", None) 177 | -------------------------------------------------------------------------------- /tests/test_payload_encoder.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import json 3 | import uuid 4 | from datetime import date, datetime 5 | 6 | import pytest 7 | 8 | from chaoslib import PayloadEncoder 9 | from chaoslib.exceptions import ChaosException 10 | 11 | 12 | def test_that_payload_encoder_handles_datetime_objects(): 13 | now = datetime.now() 14 | payload = {"test-datetime": now} 15 | payload_encoded = json.dumps(payload, cls=PayloadEncoder) 16 | assert now.isoformat() in payload_encoded 17 | 18 | 19 | def test_that_payload_encoder_handles_date_objects(): 20 | now = date.today() 21 | payload = {"test-datetime": now} 22 | payload_encoded = json.dumps(payload, cls=PayloadEncoder) 23 | assert now.isoformat() in payload_encoded 24 | 25 | 26 | def test_that_payload_encoder_handles_uuid_objects(): 27 | payload_uuid = uuid.uuid4() 28 | payload = {"test-uuid": payload_uuid} 29 | payload_encoded = json.dumps(payload, cls=PayloadEncoder) 30 | assert str(payload_uuid) in payload_encoded 31 | 32 | 33 | def test_that_payload_encoder_handles_decimal_objects(): 34 | number = decimal.Decimal(6.12) 35 | payload = {"test-decimal": number} 36 | payload_encoded = json.dumps(payload, cls=PayloadEncoder) 37 | assert str(number) in payload_encoded 38 | 39 | 40 | def test_that_payload_encoder_handles_exception_objects(): 41 | exception = ChaosException("An Exception Happened") 42 | payload = {"test-exception": exception} 43 | payload_encoded = json.dumps(payload, cls=PayloadEncoder) 44 | assert ( 45 | "An exception was raised: ChaosException('An Exception Happened')" 46 | ) in payload_encoded 47 | 48 | 49 | def test_that_payload_encoder_handles_unserialisable_object_in_base_class(): 50 | class AThing: 51 | def __init__(self, name: str) -> None: 52 | self.name = name 53 | 54 | a_thing = AThing(name="test-name") 55 | payload = {"test-thing": a_thing} 56 | with pytest.raises(TypeError): 57 | json.dumps(payload, cls=PayloadEncoder) 58 | -------------------------------------------------------------------------------- /tests/test_probe.py: -------------------------------------------------------------------------------- 1 | import json 2 | import socket 3 | import sys 4 | from http.server import BaseHTTPRequestHandler, HTTPServer 5 | from threading import Thread 6 | 7 | import pytest 8 | import requests_mock 9 | from fixtures import config, experiments, probes 10 | 11 | from chaoslib.activity import ensure_activity_is_valid, run_activity 12 | from chaoslib.exceptions import ActivityFailed, InvalidActivity 13 | 14 | 15 | def test_empty_probe_is_invalid(): 16 | with pytest.raises(InvalidActivity) as exc: 17 | ensure_activity_is_valid(probes.EmptyProbe) 18 | assert "empty activity is no activity" in str(exc.value) 19 | 20 | 21 | def test_probe_must_have_a_type(): 22 | with pytest.raises(InvalidActivity) as exc: 23 | ensure_activity_is_valid(probes.MissingTypeProbe) 24 | assert "an activity must have a type" in str(exc.value) 25 | 26 | 27 | def test_probe_must_have_a_known_type(): 28 | with pytest.raises(InvalidActivity) as exc: 29 | ensure_activity_is_valid(probes.UnknownTypeProbe) 30 | assert "'whatever' is not a supported activity type" in str(exc.value) 31 | 32 | 33 | def test_probe_provider_must_have_a_known_type(): 34 | with pytest.raises(InvalidActivity) as exc: 35 | ensure_activity_is_valid(probes.UnknownProviderTypeProbe) 36 | assert "unknown provider type 'pizza'" in str(exc.value) 37 | 38 | 39 | def test_python_probe_must_have_a_module_path(): 40 | with pytest.raises(InvalidActivity) as exc: 41 | ensure_activity_is_valid(probes.MissingModuleProbe) 42 | assert "a Python activity must have a module path" in str(exc.value) 43 | 44 | 45 | def test_python_probe_must_have_a_function_name(): 46 | with pytest.raises(InvalidActivity) as exc: 47 | ensure_activity_is_valid(probes.MissingFunctionProbe) 48 | assert "a Python activity must have a function name" in str(exc.value) 49 | 50 | 51 | def test_python_probe_must_be_importable(): 52 | with pytest.raises(InvalidActivity) as exc: 53 | ensure_activity_is_valid(probes.NotImportableModuleProbe) 54 | assert "could not find Python module 'fake.module'" in str(exc.value) 55 | 56 | 57 | def test_python_probe_func_must_have_enough_args(): 58 | with pytest.raises(InvalidActivity) as exc: 59 | ensure_activity_is_valid(probes.MissingFuncArgProbe) 60 | assert "required argument 'path' is missing" in str(exc.value) 61 | 62 | 63 | def test_python_probe_func_cannot_have_too_many_args(): 64 | with pytest.raises(InvalidActivity) as exc: 65 | ensure_activity_is_valid(probes.TooManyFuncArgsProbe) 66 | assert ( 67 | "argument 'should_not_be_here' is not part of the " 68 | "function signature" in str(exc.value) 69 | ) 70 | 71 | 72 | def test_process_probe_have_a_path(): 73 | with pytest.raises(InvalidActivity) as exc: 74 | ensure_activity_is_valid(probes.MissingProcessPathProbe) 75 | assert "a process activity must have a path" in str(exc.value) 76 | 77 | 78 | def test_process_probe_path_must_exist(): 79 | with pytest.raises(InvalidActivity) as exc: 80 | ensure_activity_is_valid(probes.ProcessPathDoesNotExistProbe) 81 | assert "path 'somewhere/not/here' cannot be found, in activity" in str( 82 | exc.value 83 | ) 84 | 85 | 86 | def test_http_probe_must_have_a_url(): 87 | with pytest.raises(InvalidActivity) as exc: 88 | ensure_activity_is_valid(probes.MissingHTTPUrlProbe) 89 | assert "a HTTP activity must have a URL" in str(exc.value) 90 | 91 | 92 | def test_run_python_probe_should_return_raw_value(): 93 | # our probe checks a file exists 94 | assert ( 95 | run_activity( 96 | probes.PythonModuleProbe, config.EmptyConfig, experiments.Secrets 97 | ) 98 | is True 99 | ) 100 | 101 | 102 | def test_run_process_probe_should_return_raw_value(): 103 | v = "Python {v}\n".format(v=sys.version.split(" ")[0]) 104 | 105 | result = run_activity( 106 | probes.ProcProbe, config.EmptyConfig, experiments.Secrets 107 | ) 108 | assert isinstance(result, dict) 109 | assert result["status"] == 0 110 | assert result["stdout"] == v 111 | assert result["stderr"] == "" 112 | 113 | 114 | def test_run_process_probe_should_pass_arguments_in_array(): 115 | args = ( 116 | "['-c', '--empty', '--number', '1', '--string', 'with spaces', '--string'," 117 | " 'a second string with the same option']\n" 118 | ) 119 | 120 | result = run_activity( 121 | probes.ProcEchoArrayProbe, config.EmptyConfig, experiments.Secrets 122 | ) 123 | assert isinstance(result, dict) 124 | assert result["status"] == 0 125 | assert result["stdout"] == args 126 | assert result["stderr"] == "" 127 | 128 | 129 | def test_run_process_probe_can_pass_arguments_as_string(): 130 | args = ( 131 | "['-c', '--empty', '--number', '1', '--string', 'with spaces', " 132 | "'--string', 'a second string with the same option']\n" 133 | ) 134 | 135 | result = run_activity( 136 | probes.ProcEchoStrProbe, config.EmptyConfig, experiments.Secrets 137 | ) 138 | assert isinstance(result, dict) 139 | assert result["status"] == 0 140 | assert result["stdout"] == args 141 | assert result["stderr"] == "" 142 | 143 | 144 | def test_run_process_probe_can_timeout(): 145 | probe = probes.ProcProbe 146 | probe["provider"]["timeout"] = 0.0001 147 | 148 | with pytest.raises(ActivityFailed) as exc: 149 | run_activity( 150 | probes.ProcProbe, config.EmptyConfig, experiments.Secrets 151 | ).decode("utf-8") 152 | assert "activity took too long to complete" in str(exc.value) 153 | 154 | 155 | def test_run_http_probe_should_return_parsed_json_value(): 156 | with requests_mock.mock() as m: 157 | headers = {"Content-Type": "application/json"} 158 | m.post("http://example.com", json=["well done"], headers=headers) 159 | result = run_activity( 160 | probes.HTTPProbe, config.EmptyConfig, experiments.Secrets 161 | ) 162 | assert result["body"] == ["well done"] 163 | 164 | 165 | def test_run_http_probe_must_be_serializable_to_json(): 166 | with requests_mock.mock() as m: 167 | headers = {"Content-Type": "application/json"} 168 | m.post("http://example.com", json=["well done"], headers=headers) 169 | result = run_activity( 170 | probes.HTTPProbe, config.EmptyConfig, experiments.Secrets 171 | ) 172 | assert json.dumps(result) is not None 173 | 174 | 175 | def test_run_http_probe_should_return_raw_text_value(): 176 | with requests_mock.mock() as m: 177 | m.post("http://example.com", text="['well done']") 178 | result = run_activity( 179 | probes.HTTPProbe, config.EmptyConfig, experiments.Secrets 180 | ) 181 | assert result["body"] == "['well done']" 182 | 183 | 184 | def test_run_http_probe_can_expect_failure(): 185 | with requests_mock.mock() as m: 186 | m.post("http://example.com", status_code=404, text="Not found!") 187 | 188 | probe = probes.HTTPProbe.copy() 189 | probe["provider"]["expected_status"] = 404 190 | 191 | try: 192 | run_activity(probe, config.EmptyConfig, experiments.Secrets) 193 | except ActivityFailed: 194 | pytest.fail("activity should not have failed") 195 | 196 | 197 | def test_run_http_probe_can_retry(): 198 | """ 199 | this test embeds a fake HTTP server to test the retry part 200 | it can't be easily tested with libraries like requests_mock or responses 201 | we could mock urllib3 retry mechanism as it is used in the requests library but it 202 | implies to understand how requests works which is not the idea of this test 203 | 204 | in this test, the first call will lead to a ConnectionAbortedError and the second 205 | will work 206 | """ 207 | 208 | class MockServerRequestHandler(BaseHTTPRequestHandler): 209 | """ 210 | mock of a real HTTP server to simulate the behavior of 211 | a connection aborted error on first call 212 | """ 213 | 214 | call_count = 0 215 | 216 | def do_GET(self): 217 | MockServerRequestHandler.call_count += 1 218 | if MockServerRequestHandler.call_count == 1: 219 | raise ConnectionAbortedError 220 | self.send_response(200) 221 | self.end_headers() 222 | return 223 | 224 | # get a free port to listen on 225 | s = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM) 226 | s.bind(("localhost", 0)) 227 | address, port = s.getsockname() 228 | s.close() 229 | 230 | # start the fake HTTP server in a dedicated thread on the selected port 231 | server = HTTPServer(("localhost", port), MockServerRequestHandler) 232 | t = Thread(target=server.serve_forever) 233 | t.setDaemon(True) 234 | t.start() 235 | 236 | # change probe URL to call the selected port 237 | probe = probes.PythonModuleProbeWithHTTPMaxRetries.copy() 238 | probe["provider"]["url"] = f"http://localhost:{port}" 239 | try: 240 | run_activity(probe, config.EmptyConfig, experiments.Secrets) 241 | except ActivityFailed: 242 | pytest.fail("activity should not have failed") 243 | -------------------------------------------------------------------------------- /tests/test_process_provider.py: -------------------------------------------------------------------------------- 1 | import locale 2 | import sys 3 | import os.path 4 | import stat 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | 9 | from chaoslib.provider.process import run_process_activity 10 | 11 | pytestmark = pytest.mark.skipif( 12 | sys.platform != "linux", reason="only run these on Linux" 13 | ) 14 | 15 | settings_dir = os.path.join(os.path.dirname(__file__), "fixtures") 16 | 17 | # the script path shall be relative to chaostoolkit-lib folder 18 | dummy_script = "./tests/dummy.sh" 19 | 20 | 21 | def setup_module(module): 22 | """ 23 | setup any state specific to the execution of the given module. 24 | 25 | - create the dummy script that can be used as process action 26 | """ 27 | with open(dummy_script, "w") as f: 28 | f.write("#!/bin/bash\n") 29 | f.write("exit 0\n") 30 | 31 | # gives exec right on the script: chmod +x 32 | st = os.stat(dummy_script) 33 | os.chmod(dummy_script, st.st_mode | stat.S_IEXEC) 34 | 35 | 36 | def teardown_module(module): 37 | """ 38 | teardown any state that was previously setup with a setup_module method. 39 | 40 | - delete the dummy script, once it's not needed anymore 41 | """ 42 | os.remove(dummy_script) 43 | 44 | 45 | def test_process_not_utf8_cannot_fail(): 46 | try: 47 | locale.setlocale(locale.LC_ALL, "C.UTF-8") 48 | result = run_process_activity( 49 | { 50 | "provider": { 51 | "type": "process", 52 | "path": "python", 53 | "arguments": ( 54 | "-c \"import locale; locale.setlocale(locale.LC_ALL, 'C.UTF-8'); import sys; sys.stdout.buffer.write(bytes('pythön', 'utf-16'))\"" # noqa 55 | ), 56 | } 57 | }, 58 | None, 59 | None, 60 | ) 61 | 62 | # unfortunately, this doesn't seem to work well on mac 63 | if result["status"] == 0: 64 | assert result["stderr"] == "" 65 | assert result["stdout"] == "pythön" # detected encoding is utf-8 66 | finally: 67 | locale.setlocale(locale.LC_ALL, None) 68 | 69 | 70 | def test_process_homedir_relative_path(): 71 | path = os.path.abspath(dummy_script).replace(os.path.expanduser("~"), "~") 72 | result = run_process_activity( 73 | {"provider": {"type": "process", "path": path, "arguments": ""}}, 74 | None, 75 | None, 76 | ) 77 | assert result["status"] == 0 78 | 79 | 80 | def test_process_absolute_path(): 81 | result = run_process_activity( 82 | { 83 | "provider": { 84 | "type": "process", 85 | "path": os.path.abspath(dummy_script), 86 | "arguments": "", 87 | } 88 | }, 89 | None, 90 | None, 91 | ) 92 | assert result["status"] == 0 93 | 94 | 95 | def test_process_cwd_relative_path(): 96 | result = run_process_activity( 97 | { 98 | "provider": { 99 | "type": "process", 100 | "path": dummy_script, 101 | "arguments": "", 102 | } 103 | }, 104 | None, 105 | None, 106 | ) 107 | assert result["status"] == 0 108 | 109 | 110 | @patch("chaoslib.provider.process.logger") 111 | def test_process_non_exit_zero_warning(logger): 112 | run_process_activity( 113 | { 114 | "provider": { 115 | "type": "process", 116 | "path": "python", 117 | "arguments": '-c "import sys; sys.exit(1)"', 118 | } 119 | }, 120 | None, 121 | None, 122 | ) 123 | 124 | assert logger.warning.call_count == 1 125 | assert ( 126 | "This process returned a non-zero exit code." 127 | in logger.warning.call_args[0][0] 128 | ) 129 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from chaoslib.settings import ( 4 | get_loaded_settings, 5 | load_settings, 6 | locate_settings_entry, 7 | save_settings, 8 | ) 9 | 10 | settings_dir = os.path.join(os.path.dirname(__file__), "fixtures") 11 | 12 | 13 | def test_do_not_fail_when_settings_do_not_exist(): 14 | assert load_settings(os.path.join(settings_dir, "no_settings.yaml")) is None 15 | 16 | 17 | def test_load_settings(): 18 | settings = load_settings(os.path.join(settings_dir, "settings.yaml")) 19 | assert "notifications" in settings 20 | 21 | 22 | def test_save_settings(): 23 | settings = load_settings(os.path.join(settings_dir, "settings.yaml")) 24 | new_settings_location = os.path.join(settings_dir, "new_settings.yaml") 25 | try: 26 | os.remove(new_settings_location) 27 | except OSError: 28 | pass 29 | save_settings(settings, new_settings_location) 30 | saved_settings = load_settings(new_settings_location) 31 | assert "notifications" in saved_settings 32 | os.remove(new_settings_location) 33 | 34 | 35 | def test_load_unsafe_settings(): 36 | settings = load_settings(os.path.join(settings_dir, "unsafe-settings.yaml")) 37 | assert settings is None 38 | 39 | 40 | def test_create_settings_file_on_save(): 41 | ghost = os.path.abspath(os.path.join(settings_dir, "bah", "ghost.yaml")) 42 | assert not os.path.exists(ghost) 43 | try: 44 | save_settings({}, ghost) 45 | assert os.path.exists(ghost) 46 | finally: 47 | try: 48 | os.remove(ghost) 49 | except OSError: 50 | pass 51 | 52 | 53 | def test_get_loaded_settings(): 54 | settings = load_settings(os.path.join(settings_dir, "settings.yaml")) 55 | assert get_loaded_settings() is settings 56 | 57 | 58 | def test_locate_root_level_entry(): 59 | settings = {"auths": {"chaos.example.com": {"type": "bearer"}}} 60 | parent, entry, k, i = locate_settings_entry(settings, "auths") 61 | assert parent == settings 62 | assert entry == settings["auths"] 63 | assert k == "auths" 64 | assert i is None 65 | 66 | 67 | def test_locate_dotted_entry(): 68 | settings = {"auths": {"chaos.example.com": {"type": "bearer"}}} 69 | parent, entry, k, i = locate_settings_entry( 70 | settings, "auths.chaos\\.example\\.com" 71 | ) 72 | assert parent == settings["auths"] 73 | assert entry == {"type": "bearer"} 74 | assert k == "chaos.example.com" 75 | assert i is None 76 | 77 | 78 | def test_locate_indexed_entry(): 79 | settings = { 80 | "auths": { 81 | "chaos.example.com": { 82 | "type": "bearer", 83 | "headers": [ 84 | {"name": "X-Client", "value": "blah"}, 85 | {"name": "X-For", "value": "other"}, 86 | ], 87 | } 88 | } 89 | } 90 | parent, entry, k, i = locate_settings_entry( 91 | settings, "auths.chaos\\.example\\.com.headers[1]" 92 | ) 93 | assert parent == settings["auths"]["chaos.example.com"]["headers"] 94 | assert entry == {"name": "X-For", "value": "other"} 95 | assert k is None 96 | assert i == 1 97 | 98 | 99 | def test_locate_dotted_key_from_indexed_entry(): 100 | settings = { 101 | "auths": { 102 | "chaos.example.com": { 103 | "type": "bearer", 104 | "headers": [ 105 | {"name": "X-Client", "value": "blah"}, 106 | {"name": "X-For", "value": "other"}, 107 | ], 108 | } 109 | } 110 | } 111 | parent, entry, k, i = locate_settings_entry( 112 | settings, "auths.chaos\\.example\\.com.headers[1].name" 113 | ) 114 | assert parent == settings["auths"]["chaos.example.com"]["headers"][1] 115 | assert entry == "X-For" 116 | assert k == "name" 117 | assert i is None 118 | 119 | 120 | def test_cannot_locate_dotted_entry(): 121 | settings = {"auths": {"chaos.example.com": {"type": "bearer"}}} 122 | assert locate_settings_entry(settings, "auths.chaos.example.com") is None 123 | -------------------------------------------------------------------------------- /tests/test_substitution.py: -------------------------------------------------------------------------------- 1 | from fixtures import config 2 | 3 | from chaoslib import substitute 4 | from chaoslib.configuration import load_configuration 5 | from chaoslib.hypothesis import run_steady_state_hypothesis, within_tolerance 6 | from chaoslib.provider.http import run_http_activity 7 | from chaoslib.run import EventHandlerRegistry 8 | 9 | 10 | def test_substitute_strings_from_configuration(): 11 | new_args = substitute("hello ${name}", config.SomeConfig, None) 12 | 13 | assert new_args == "hello Jane" 14 | 15 | 16 | def test_substitute_from_configuration(): 17 | args = {"message": "hello ${name}"} 18 | 19 | new_args = substitute(args, config.SomeConfig, None) 20 | 21 | assert new_args["message"] == "hello Jane" 22 | 23 | 24 | def test_substitute_from_secrets(): 25 | args = {"message": "hello ${name}"} 26 | 27 | new_args = substitute(args, None, {"ident": {"name": "Joe"}}) 28 | 29 | assert new_args["message"] == "hello Joe" 30 | 31 | 32 | def test_substitute_from_config_and_secrets_with_priority_to_config(): 33 | args = {"message": "hello ${name}"} 34 | 35 | new_args = substitute(args, config.SomeConfig, {"ident": {"name": "Joe"}}) 36 | 37 | assert new_args["message"] == "hello Jane" 38 | 39 | 40 | def test_do_not_fail_when_key_is_missing(): 41 | args = {"message": "hello ${firstname}"} 42 | 43 | new_args = substitute(args, config.SomeConfig, None) 44 | 45 | assert new_args["message"] == "hello ${firstname}" 46 | 47 | 48 | # see https://github.com/chaostoolkit/chaostoolkit-lib/issues/195 49 | def test_use_nested_object_as_substitution(): 50 | config = load_configuration( 51 | {"nested": {"onea": "fdsfdsf", "lol": {"haha": [1, 2, 3]}}} 52 | ) 53 | 54 | result = substitute("${nested}", configuration=config, secrets=None) 55 | assert isinstance(result, dict) 56 | assert result == {"onea": "fdsfdsf", "lol": {"haha": [1, 2, 3]}} 57 | 58 | 59 | # see https://github.com/chaostoolkit/chaostoolkit-lib/issues/180 60 | def test_use_integer_as_substitution(): 61 | config = load_configuration({"value": 8}) 62 | 63 | result = substitute("${value}", configuration=config, secrets=None) 64 | assert isinstance(result, int) 65 | assert result == 8 66 | 67 | 68 | def test_always_return_to_string_when_pattern_is_not_alone(): 69 | config = load_configuration({"value": 8}) 70 | 71 | result = substitute("hello ${value}", configuration=config, secrets=None) 72 | assert isinstance(result, str) 73 | assert result == "hello 8" 74 | 75 | 76 | def test_http_activity_can_substitute_timeout() -> None: 77 | c = {"my_timeout": 1} 78 | run_http_activity( 79 | { 80 | "provider": { 81 | "url": "https://www.google.com", 82 | "timeout": "${my_timeout}", 83 | } 84 | }, 85 | c, 86 | {}, 87 | ) 88 | 89 | 90 | def test_jsonpath_can_substitute_expect() -> None: 91 | r = within_tolerance( 92 | { 93 | "type": "jsonpath", 94 | "path": "$.RecordData[0]", 95 | "expect": ["my-other-cname.${env}.mysite.com"], 96 | }, 97 | {"RecordData": ["my-other-cname.dev.mysite.com"]}, 98 | {"env": "dev"}, 99 | {}, 100 | ) 101 | 102 | assert r is True 103 | 104 | 105 | def test_tolerance_substitution() -> None: 106 | registry = EventHandlerRegistry() 107 | state = run_steady_state_hypothesis( 108 | experiment={ 109 | "steady-state-hypothesis": { 110 | "title": "", 111 | "probes": [ 112 | { 113 | "name": "check-stuff", 114 | "type": "probe", 115 | "tolerance": "${expected}", 116 | "provider": { 117 | "type": "python", 118 | "module": "statistics", 119 | "func": "mean", 120 | "arguments": {"data": [1, 3, 4, 4]}, 121 | }, 122 | } 123 | ], 124 | } 125 | }, 126 | configuration={"expected": 3}, 127 | secrets={}, 128 | dry=False, 129 | event_registry=registry, 130 | ) 131 | 132 | assert state["steady_state_met"] is True 133 | 134 | 135 | def test_tolerance_substitution_is_noop_on_non_var() -> None: 136 | registry = EventHandlerRegistry() 137 | state = run_steady_state_hypothesis( 138 | experiment={ 139 | "steady-state-hypothesis": { 140 | "title": "", 141 | "probes": [ 142 | { 143 | "name": "check-stuff", 144 | "type": "probe", 145 | "tolerance": "6", 146 | "provider": { 147 | "type": "python", 148 | "module": "statistics", 149 | "func": "mean", 150 | "arguments": {"data": [1, 3, 4, 4]}, 151 | }, 152 | } 153 | ], 154 | } 155 | }, 156 | configuration={"expected": 3}, 157 | secrets={}, 158 | dry=False, 159 | event_registry=registry, 160 | ) 161 | 162 | assert state["steady_state_met"] is False 163 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from chaoslib import convert_to_type, decode_bytes 4 | 5 | 6 | def test_can_convert_to_bool(): 7 | assert convert_to_type("bool", "false") is False 8 | assert convert_to_type("bool", "0") is False 9 | assert convert_to_type("bool", "no") is False 10 | assert convert_to_type("bool", "true") is True 11 | assert convert_to_type("bool", "1") is True 12 | assert convert_to_type("bool", "yes") is True 13 | 14 | 15 | def test_can_convert_to_int(): 16 | assert convert_to_type("int", "17") == 17 17 | assert convert_to_type("integer", "95") == 95 18 | 19 | 20 | def test_can_convert_to_float(): 21 | assert convert_to_type("float", "17.76") == 17.76 22 | assert convert_to_type("number", "95.89") == 95.89 23 | assert convert_to_type("float", "17") == 17.0 24 | 25 | 26 | def test_can_convert_to_str(): 27 | assert convert_to_type("str", "hello") == "hello" 28 | assert convert_to_type("string", "hello") == "hello" 29 | 30 | 31 | def test_can_convert_to_bytes(): 32 | assert convert_to_type("bytes", "hello") == b"hello" 33 | 34 | 35 | def test_can_convert_to_json(): 36 | assert convert_to_type("json", '{"a": 67}') == {"a": 67} 37 | 38 | 39 | def test_no_type_is_bypass(): 40 | assert convert_to_type(None, "true") == "true" 41 | 42 | 43 | def test_cannot_convert_unknown_type(): 44 | with pytest.raises(ValueError): 45 | convert_to_type("yaml", "true") == "true" 46 | 47 | 48 | def test_can_convert_to_json_is_silent_when_no_value_given(): 49 | assert convert_to_type("json", "") == "" 50 | 51 | 52 | def test_decode_bytes(): 53 | assert decode_bytes("noël".encode("utf-8")) == "noël" 54 | --------------------------------------------------------------------------------