Skip to content

Commit 550ce35

Browse files
qa/tasks: add template.py for generic templating needs
This change extracts a bunch of functionality that we added to cephadm.py and has been "incubating" there for too long as it's not particularly specific to cephadm. Take the code from cephadm.py and fully generalize it and make it a fairly simple module that can be reused elsewhere as needed. Signed-off-by: John Mulligan <[email protected]>
1 parent e97d811 commit 550ce35

File tree

1 file changed

+163
-0
lines changed

1 file changed

+163
-0
lines changed

qa/tasks/template.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""
2+
General template support for teuthology tasks.
3+
4+
Functions in this module allow tests to template strings. For example:
5+
```
6+
template.exec:
7+
host.x:
8+
- echo {{ctx.foo.bar}}
9+
```
10+
11+
Functions like transform allow you to use this templating support
12+
as a building block in your own .py files as well.
13+
14+
By default a template can access the variables `ctx` and `config` - these are
15+
mapped to the first two arguments of this function and should match the ctx and
16+
config passed to an individual task. Additional vars `cluster` and `VIP<N>`
17+
(eg. VIP0, VIP1), `VIPPREFIXLEN`, `VIPSUBNET` are available for convenience
18+
and/or backwards compatiblity with existing tests. Finally, keyword args may be
19+
passed (via `ctx_vars`) to transform to add specific top-level
20+
variables to extend the templating for specific use-cases.
21+
22+
Templates can access filters that transform on value into another. Currently,
23+
the only filter, not part of the jinja2 default filters, available is
24+
`role_to_remote`. Given a role name (like 'host.a') this returns the
25+
teuthology remote object corresponding to the *first* matching role.
26+
A template can then access the remote's properties as needed, for example to get
27+
a host's IP address. Example:
28+
```
29+
template.exec:
30+
host.x:
31+
- pip install foobarbuzz
32+
- fbbuzz quuxify -q --extended {{role|role_to_remote|attr('ip_address')}}
33+
```
34+
35+
36+
"""
37+
38+
import functools
39+
import logging
40+
41+
import jinja2
42+
from teuthology import misc as teuthology
43+
44+
45+
log = logging.getLogger(__name__)
46+
47+
48+
def _convert_strs_in(obj, conv):
49+
"""A function to walk the contents of a dict/list and recursively apply
50+
a conversion function (`conv`) to the strings within.
51+
"""
52+
if isinstance(obj, str):
53+
return conv(obj)
54+
if isinstance(obj, dict):
55+
for k in obj:
56+
obj[k] = _convert_strs_in(obj[k], conv)
57+
if isinstance(obj, list):
58+
obj[:] = [_convert_strs_in(v, conv) for v in obj]
59+
return obj
60+
61+
62+
def _apply_template(jinja_env, rctx, template):
63+
"""Apply jinja2 templating to the template string `template` via the jinja
64+
environment `jinja_env`, passing a dictionary containing top-level context
65+
to render into the template.
66+
"""
67+
if "{{" in template or "{%" in template:
68+
return jinja_env.from_string(template).render(**rctx)
69+
return template
70+
71+
72+
@jinja2.pass_context
73+
def _role_to_remote(rctx, role):
74+
"""Return the first remote matching the given role."""
75+
ctx = rctx["ctx"]
76+
for remote, roles in ctx.cluster.remotes.items():
77+
if role in roles:
78+
return remote
79+
return None
80+
81+
82+
def _vip_vars(rctx):
83+
"""For backwards compat with the vip.subst_vip function."""
84+
# Make it possible to replace subst_vip in vip.py.
85+
ctx = rctx["ctx"]
86+
if "vnet" in getattr(ctx, "vip", {}):
87+
rctx["VIPPREFIXLEN"] = str(ctx.vip["vnet"].prefixlen)
88+
rctx["VIPSUBNET"] = str(ctx.vip["vnet"].network_address)
89+
if "vips" in getattr(ctx, "vip", {}):
90+
vips = ctx.vip["vips"]
91+
for idx, vip in enumerate(vips):
92+
rctx[f"VIP{idx}"] = str(vip)
93+
94+
95+
def transform(ctx, config, target, **ctx_vars):
96+
"""Apply jinja2 based templates to strings within the target object,
97+
returning a transformed target. Target objects may be a list or dict or
98+
str.
99+
100+
Note that only string values in the list or dict objects are modified.
101+
Therefore one can read & parse yaml or json that contain templates in
102+
string values without the risk of changing the structure of the yaml/json.
103+
"""
104+
jenv = getattr(ctx, "_jinja_env", None)
105+
if jenv is None:
106+
loader = jinja2.BaseLoader()
107+
jenv = jinja2.Environment(loader=loader)
108+
jenv.filters["role_to_remote"] = _role_to_remote
109+
setattr(ctx, "_jinja_env", jenv)
110+
rctx = dict(
111+
ctx=ctx, config=config, cluster_name=config.get("cluster", "")
112+
)
113+
_vip_vars(rctx)
114+
rctx.update(ctx_vars)
115+
conv = functools.partial(_apply_template, jenv, rctx)
116+
return _convert_strs_in(target, conv)
117+
118+
119+
def expand_roles(ctx, config):
120+
"""Given a context and a config dict containing a mapping of test roles
121+
to role actions (typically commands to exec). Expand the special role
122+
macros `all-roles` and `all-hosts` into role names that can be found
123+
in the teuthology config.
124+
"""
125+
if "all-roles" in config and len(config) == 1:
126+
a = config["all-roles"]
127+
roles = teuthology.all_roles(ctx.cluster)
128+
config = dict(
129+
(id_, a) for id_ in roles if not id_.startswith("host.")
130+
)
131+
elif "all-hosts" in config and len(config) == 1:
132+
a = config["all-hosts"]
133+
roles = teuthology.all_roles(ctx.cluster)
134+
config = dict((id_, a) for id_ in roles if id_.startswith("host."))
135+
elif "all-roles" in config or "all-hosts" in config:
136+
raise ValueError(
137+
"all-roles/all-hosts may not be combined with any other roles"
138+
)
139+
return config
140+
141+
142+
def exec(ctx, config):
143+
"""
144+
This is similar to the standard 'exec' task, but does template substitutions.
145+
"""
146+
assert isinstance(config, dict), "task exec got invalid config"
147+
testdir = teuthology.get_testdir(ctx)
148+
config = expand_roles(ctx, config)
149+
for role, ls in config.items():
150+
(remote,) = ctx.cluster.only(role).remotes.keys()
151+
log.info("Running commands on role %s host %s", role, remote.name)
152+
for c in ls:
153+
c.replace("$TESTDIR", testdir)
154+
remote.run(
155+
args=[
156+
"sudo",
157+
"TESTDIR={tdir}".format(tdir=testdir),
158+
"bash",
159+
"-ex",
160+
"-c",
161+
transform(ctx, config, c, role=role),
162+
],
163+
)

0 commit comments

Comments
 (0)