Skip to content

Commit 79befdb

Browse files
authored
Merge pull request #182 from chaostoolkit/chaostoolkit/issue175-revisited
Add var and var files support
2 parents 75d3c3a + 285eb6b commit 79befdb

File tree

7 files changed

+314
-17
lines changed

7 files changed

+314
-17
lines changed

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@
44

55
[Unreleased]: https://github.com/chaostoolkit/chaostoolkit-lib/compare/1.11.1...HEAD
66

7+
### Added
8+
9+
- Added ways to override configuration and secrets from var files or passed to
10+
the chaostoolkit cli [chaostoolkit#175][].
11+
12+
13+
[chaostoolkit#175]: https://github.com/chaostoolkit/chaostoolkit/issues/175
14+
715
## [1.11.1][] - 2020-07-29
816

917
[1.11.1]: https://github.com/chaostoolkit/chaostoolkit-lib/compare/1.11.0...1.11.1
1018

11-
## Changed
19+
### Changed
1220

1321
- Controls can now update/change configuration and secrets on the fly as per
1422
the specification [#181][181]

chaoslib/__init__.py

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# -*- coding: utf-8 -*-
22
from collections import ChainMap
3+
import os.path
34
from string import Template
4-
from typing import Any, Dict, List, Mapping, Union
5+
from typing import Any, Dict, List, Mapping, Tuple, Union
56

67
HAS_CHARDET = True
78
try:
@@ -12,11 +13,19 @@
1213
except ImportError:
1314
HAS_CHARDET = False
1415
from logzero import logger
16+
try:
17+
import simplejson as json
18+
from simplejson.errors import JSONDecodeError
19+
except ImportError:
20+
import json
21+
from json.decoder import JSONDecodeError
22+
import yaml
1523

1624
from chaoslib.exceptions import ActivityFailed
17-
from chaoslib.types import Configuration, Secrets
25+
from chaoslib.types import Configuration, ConfigVars, Secrets, SecretVars
1826

19-
__all__ = ["__version__", "decode_bytes", "substitute"]
27+
__all__ = ["__version__", "decode_bytes", "substitute", "merge_vars",
28+
"convert_vars"]
2029
__version__ = '1.11.1'
2130

2231

@@ -119,3 +128,126 @@ def decode_bytes(data: bytes, default_encoding: str = 'utf-8') -> str:
119128
except UnicodeDecodeError:
120129
raise ActivityFailed(
121130
"Failed to decode bytes using encoding '{}'".format(encoding))
131+
132+
133+
def merge_vars(var: Dict[str, Union[str, float, int, bytes]] = None, # noqa: C901
134+
var_files: List[str] = None) -> Tuple[ConfigVars, SecretVars]:
135+
"""
136+
Load configuration and secret values from the given set of variables.
137+
These values are applicable for substitution when the experiment runs.
138+
If `var` is set, it must be a dictionary which will be used as
139+
configuration values only.
140+
If `var_files` is set, it can be a list of any of these two items:
141+
* a Json or Yaml payload which must be also mappings with two top-level
142+
keys: `"configuration"` and `"secrets"`. If any is present, it must
143+
respect the format of the confiuration and secrets of the experiment
144+
format.
145+
* a .env file which is used to load environment variable on the fly.
146+
In that case, the values are injected in the process environment so that
147+
they get picked up during experiment's execution as if they had been
148+
from the terminal itself.
149+
Note that, when multiple var files are provided, they can override each
150+
other.
151+
The output of this function is a tuple made of configuration and secrets
152+
that will be used during the experiment's execution for lookup.
153+
"""
154+
config_vars = {}
155+
secret_vars = {}
156+
157+
if var_files:
158+
for var_file in var_files:
159+
logger.debug("Loading var from file '{}'".format(var_file))
160+
161+
if not os.path.isfile(var_file):
162+
logger.error("Cannot read var file '{}'".format(var_file))
163+
continue
164+
165+
content = None
166+
with open(var_file) as f:
167+
content = f.read()
168+
169+
if not content:
170+
logger.debug("Var file '{}' is empty".format(var_file))
171+
continue
172+
173+
data = None
174+
_, ext = os.path.splitext(var_file)
175+
if ext in (".yaml", ".yml"):
176+
try:
177+
data = yaml.safe_load(content)
178+
except yaml.YAMLError as y:
179+
logger.error(
180+
"Failed to parse variable file '{}': {}".format(
181+
var_file, str(y)))
182+
continue
183+
elif ext in (".json"):
184+
try:
185+
data = json.loads(content)
186+
except JSONDecodeError as x:
187+
logger.error(
188+
"Failed to parse variable file '{}': {}".format(
189+
var_file, str(x)))
190+
continue
191+
192+
# process .env files
193+
if not data:
194+
for line in content.split(os.linesep):
195+
line = line.strip()
196+
if not line or line.startswith("#"):
197+
continue
198+
199+
k, v = line.split('=', 1)
200+
os.environ[k] = v
201+
logger.debug(
202+
"Inject environment variable '{}' from "
203+
"file '{}'".format(k, var_file))
204+
else:
205+
logger.debug(
206+
"Reading configuration/secrets from {}".format(f.name))
207+
config_vars.update(data.get("configuration", {}))
208+
secret_vars.update(data.get("secrets", {}))
209+
210+
if var:
211+
for k in var:
212+
logger.debug("Using configuration variable '{}'".format(k))
213+
config_vars[k] = var[k]
214+
215+
return (config_vars, secret_vars)
216+
217+
218+
def convert_vars(value: List[str]) -> Dict[str, Any]: # noqa: C901
219+
"""
220+
Process all variables and return a dictionnary of them with the
221+
value converted to the appropriate type.
222+
223+
The list of values is as follows: `key[:type]=value` with `type` being one
224+
of: str, int, float and bytes. `str` is the default and can be omitted.
225+
"""
226+
var = {}
227+
for v in value:
228+
try:
229+
k, v = v.split('=', 1)
230+
if ':' in k:
231+
k, typ = k.rsplit(':', 1)
232+
try:
233+
if typ == 'str':
234+
pass
235+
elif typ == 'int':
236+
v = int(v)
237+
elif typ == 'float':
238+
v = float(v)
239+
elif typ == 'bytes':
240+
v = v.encode('utf-8')
241+
else:
242+
raise ValueError(
243+
'var supports only: str, int, float and bytes')
244+
except (TypeError, UnicodeEncodeError):
245+
raise ValueError(
246+
'var cannot convert value to required type')
247+
var[k] = v
248+
except ValueError:
249+
raise
250+
except Exception:
251+
raise ValueError('var needs to be in the format name[:type]=value')
252+
253+
return var

chaoslib/caching.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# referenced from other places in the experiment
44
from functools import wraps
55
import inspect
6-
from typing import List, Union
6+
from typing import Any, List, Dict, Union
77

88
from logzero import logger
99

@@ -49,7 +49,8 @@ def with_cache(f):
4949
function.
5050
"""
5151
@wraps(f)
52-
def wrapped(experiment: Experiment, settings: Settings = None):
52+
def wrapped(experiment: Experiment, settings: Settings = None,
53+
experiment_vars: Dict[str, Any] = None):
5354
try:
5455
if experiment:
5556
cache_activities(experiment)
@@ -58,8 +59,13 @@ def wrapped(experiment: Experiment, settings: Settings = None):
5859
arguments = {
5960
"experiment": experiment
6061
}
62+
6163
if "settings" in sig.parameters:
6264
arguments["settings"] = settings
65+
66+
if "experiment_vars" in sig.parameters:
67+
arguments["experiment_vars"] = experiment_vars
68+
6369
return f(**arguments)
6470
finally:
6571
clear_cache()

chaoslib/configuration.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# -*- coding: utf-8 -*-
22
import os
3-
from typing import Dict
3+
from typing import Any, Dict
44

55
from logzero import logger
66

@@ -10,7 +10,8 @@
1010
__all__ = ["load_configuration"]
1111

1212

13-
def load_configuration(config_info: Dict[str, str]) -> Configuration:
13+
def load_configuration(config_info: Dict[str, str],
14+
extra_vars: Dict[str, Any] = None) -> Configuration:
1415
"""
1516
Load the configuration. The `config_info` parameter is a mapping from
1617
key strings to value as strings or dictionaries. In the former case, the
@@ -43,22 +44,30 @@ def load_configuration(config_info: Dict[str, str]) -> Configuration:
4344
variable. The `host` configuration key is dynamically fetched from the
4445
`HOSTNAME` environment variable, but if not defined, the default value
4546
`localhost` will be used instead.
47+
48+
When `extra_vars` is provided, it must be a dictionnary where keys map
49+
to configuration key. The values from `extra_vars` always override the
50+
values from the experiment itself. This is useful to the Chaos Toolkit
51+
CLI mostly to allow overriding values directly from cli arguments. It's
52+
seldom required otherwise.
4653
"""
4754
logger.debug("Loading configuration...")
4855
env = os.environ
56+
extra_vars = extra_vars or {}
4957
conf = {}
5058

5159
for (key, value) in config_info.items():
5260
if isinstance(value, dict) and "type" in value:
5361
if value["type"] == "env":
5462
env_key = value["key"]
5563
env_default = value.get("default")
56-
if (env_key not in env) and (env_default is None):
64+
if (env_key not in env) and (env_default is None) and \
65+
(key not in extra_vars):
5766
raise InvalidExperiment(
5867
"Configuration makes reference to an environment key"
5968
" that does not exist: {}".format(env_key))
60-
conf[key] = env.get(env_key, env_default)
69+
conf[key] = extra_vars.get(key, env.get(env_key, env_default))
6170
else:
62-
conf[key] = value
71+
conf[key] = extra_vars.get(key, value)
6372

6473
return conf

chaoslib/experiment.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime
44
import platform
55
import time
6-
from typing import List
6+
from typing import Any, Dict, List
77

88
from logzero import logger
99

@@ -157,8 +157,8 @@ def get_background_pools(experiment: Experiment) -> ThreadPoolExecutor:
157157

158158

159159
@with_cache
160-
def run_experiment(experiment: Experiment,
161-
settings: Settings = None) -> Journal:
160+
def run_experiment(experiment: Experiment, settings: Settings = None,
161+
experiment_vars: Dict[str, Any] = None) -> Journal:
162162
"""
163163
Run the given `experiment` method step by step, in the following sequence:
164164
steady probe, action, close probe.
@@ -185,7 +185,9 @@ def run_experiment(experiment: Experiment,
185185

186186
started_at = time.time()
187187
settings = settings if settings is not None else get_loaded_settings()
188-
config = load_configuration(experiment.get("configuration", {}))
188+
config_vars, secret_vars = experiment_vars or (None, None)
189+
config = load_configuration(
190+
experiment.get("configuration", {}), extra_vars=config_vars)
189191
secrets = load_secrets(experiment.get("secrets", {}), config)
190192
initialize_global_controls(experiment, config, secrets, settings)
191193
initialize_controls(experiment, config, secrets)

chaoslib/types.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"TargetLayers", "Activity", "Journal", "Run", "Secrets", "Step",
66
"Configuration", "Discovery", "DiscoveredActivities", "Extension",
77
"DiscoveredSystemInfo", "Settings", "EventPayload", "Tolerance",
8-
"Hypothesis", "Control"]
8+
"Hypothesis", "Control", "ConfigVars", "SecretVars"]
99

1010

1111
Action = Dict[str, Any]
@@ -37,3 +37,6 @@
3737
Extension = Dict[str, Any]
3838
Hypothesis = Dict[str, Any]
3939
Control = Dict[str, Any]
40+
41+
ConfigVars = Dict[str, Any]
42+
SecretVars = Dict[str, Any]

0 commit comments

Comments
 (0)