This is part of a series of posts on ideas for an ansible-like provisioning system, implemented in Transilience.
While experimenting with Transilience, I've been giving some thought about Ansible variables.
My gripes
I like the possibility to define host and group variables, and I like to have a set of variables that are autodiscovered on the target systems.
I do not like to have everything all blended in a big bucket of global variables.
Let's try some more prototyping.
My fiddlings
First, Role
classes could become dataclasses, too, and declare the variables and
facts that they intend to use (typed, even!):
class Role(role.Role):
"""
Postfix mail server configuration
"""
# Postmaster username
postmaster: str = None
# Public name of the mail server
myhostname: str = None
# Email aliases defined on this mail server
aliases: Dict[str, str] = field(default_factory=dict)
Using dataclasses.asdict()
I immediately gain context variables for rendering Jinja2 templates:
class Role:
# [...]
def render_file(self, path: str, **kwargs):
"""
Render a Jinja2 template from a file, using as context all Role fields,
plus the given kwargs.
"""
ctx = asdict(self)
ctx.update(kwargs)
return self.template_engine.render_file(path, ctx)
def render_string(self, template: str, **kwargs):
"""
Render a Jinja2 template from a string, using as context all Role fields,
plus the given kwargs.
"""
ctx = asdict(self)
ctx.update(kwargs)
return self.template_engine.render_string(template, ctx)
I can also model results from fact gathering into dataclass members:
# From ansible/module_utils/facts/system/platform.py
@dataclass
class Platform(Facts):
"""
Facts from the platform module
"""
ansible_system: Optional[str] = None
ansible_kernel: Optional[str] = None
ansible_kernel: Optional[str] = None
ansible_kernel_version: Optional[str] = None
ansible_machine: Optional[str] = None
# [...]
ansible_userspace_architecture: Optional[str] = None
ansible_machine_id: Optional[str] = None
def summary(self):
return "gather platform facts"
def run(self, system: transilience.system.System):
super().run(system)
# ... collect facts
I like that this way, one can explicitly declare what variables a Facts
action will collect, and what variables a Role
needs.
At this point, I can add machinery to allow a Role
to declare what Facts
it
needs, and automatically have the fields from the Facts
class added to the
Role
class. Then, when facts are gathered, I can make sure that their fields
get copied over to the Role
classes that use them.
In a way, variables become role-scoped, and Facts
subclasses can be used like
some kind of Role
mixin, that contributes only field members:
# Postfix mail server configuration
@role.with_facts([actions.facts.Platform])
class Role(role.Role):
# Postmaster username
postmaster: str = None
# Public name of the mail server
myhostname: str = None
# Email aliases defined on this mail server
aliases: Dict[str, str] = field(default_factory=dict)
# All fields from actions.facts.Platform are inherited here!
def have_facts(self, facts):
# self.ansible_domain comes from actions.facts.Platform
self.add(builtin.command(
argv=["certbot", "certonly", "-d", f"mail.{self.ansible_domain}", "-n", "--apache"],
creates=f"/etc/letsencrypt/live/mail.{self.ansible_domain}/fullchain.pem"
), name="obtain mail.* certificate")
# the template context will have the Role variables, plus the variables
# of all the Facts the Role uses
with self.notify(ReloadPostfix):
self.add(builtin.copy(
dest="/etc/postfix/main.cf",
content=self.render_file("roles/mailserver/templates/main.cf"),
), name="configure /etc/postfix/main.cf")
One can also fill in variables when instantiating Roles, making parameterized generic Roles possible and easy:
runner.add_role(
"mailserver",
postmaster="enrico",
myhostname="mail.enricozini.org",
aliases={
"me": "enrico",
},
)
Outcomes
I like where this is going: having well defined variables for facts and roles, means that the variables that get into play can be explicitly defined, well known, and documented.
I think this design lends itself quite well to role reuse:
- Roles can use variables without risking interfering with each other.
- Variables from facts can have well defined meanings across roles.
- Roles are classes, and can easily be made inheritable.
I have a feeling that, this way, it may be much easier to create generic libraries of Roles that one can reuse to compose complex playbooks.
Since roles are just Python modules, we even already know how to package and distribute them!
Next step: Playbooks, host vars, group vars.