├── .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 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
--------------------------------------------------------------------------------