pyinfra-dev / pyinfra

pyinfra turns Python code into shell commands and runs them on your servers. Execute ad-hoc commands and write declarative operations. Target SSH servers, local machine and Docker containers. Fast and scales from one server to thousands.
https://pyinfra.com
MIT License
3.87k stars 377 forks source link

systemd.service() does not support wildcards #1207

Open NichtJens opened 1 week ago

NichtJens commented 1 week ago

Is your feature request related to a problem? Please describe

Commands like systemctl stop abc* support glob-style wildcards.

However, systemd.service() does not.

Describe the solution you'd like

The obvious "user space" solution is probably something like this:


from fnmatch import fnmatch

from pyinfra import host
from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import systemd

def services(pattern, name=None, **kwargs):
    """
    Manage the state of all systemd services matching a glob pattern
    """
    services = get_enabled_services()

    for service in services:
        if fnmatch(service, pattern):
            iname = None if name is None else f"{name}: {service}" 

            systemd.service(
                name=iname,
                service=service,
                **kwargs
            )

def get_enabled_services():
    """
    Return a list of enabled systemd services
    """
    units = host.get_fact(SystemdEnabled)
    return [name for name, enabled in units.items() if enabled and name.endswith(".service")]

However, this works through the list of services step by step, which means it accumulates the run time. Doing the same operation via the systemctl command will perform all changes in parallel and needs only approximately the longest run time of any of the changes.

Reading the underlying code, it turns out that this basically already works. The smallest possible change that resolves the issue would be the following:

In pyinfra/pyinfra/operations/util/service.py replace line 19

is_running = statuses.get(name, None)

with

is_running = any(s for n, s in statuses.items() if fnmatch(n, name))

I.e., if any of the services that match the pattern is running, treat the pattern as running. This is compatible with the current non-wildcard name behavior since fnmatch matches non-wildcard strings to themselves.

The question is whether this is enough or if it is wished that the list of services that is interacted with is shown in the printed output. This would need a much bigger change, I fear.

Either way, I would be happy to provide a PR for the above solution (plus the needed docs changes, etc.) or work on a more complex solution as I would really like this feature to exist.

NichtJens commented 1 week ago

Thinking about this further, there is a chance for a performance impact for the non-wildcard case if there are many services -- simply because matching every item in a large dict is worse than a single lookup in the dict. To mitigate this:

is_running = statuses.get(name, None)
if is_running is None:
    matching_is_running = [s for n, s in statuses.items() if fnmatch(n, name)]
    if matching_is_running:
        is_running = any(matching_is_running)

So, only if the lookup fails find the matches. If there is no match at all, stay with is_running=None (this was potentially wrong in the simpler solution above). If there are matches, use any() to get the overall state of the pattern matches.