config_ninja.systemd

Generate a systemd unit file for installation as a service.

The following jinja2 template is used to generate the systemd unit file:

[Unit]
Description=config synchronization daemon
After=network.target

[Service]
Environment=PYTHONUNBUFFERED=true
{% if environ -%}
{% for key, value in environ.items() -%}
Environment={{ key }}={{ value }}
{% endfor -%}
{% endif -%}
ExecStartPre={{ config_ninja_cmd }} self print
ExecStart={{ config_ninja_cmd }} apply --poll
Restart=always
RestartSec=30s
Type=notify
{%- if user %}
User={{ user }}
{%- endif %}
{%- if group %}
Group={{ group }}
{%- endif %}
{%- if workdir %}
WorkingDirectory={{ workdir }}
{%- endif %}

[Install]
{%- if not user_mode %}
WantedBy=multi-user.target
{%- endif %}
Alias=config-ninja.service

Run the CLI's install command to install the service:

 config-ninja self install --env AWS_PROFILE --user
Installing /home/ubuntu/.config/systemd/user/config-ninja.service
● config-ninja.service - config synchronization daemon
     Loaded: loaded (/home/ubuntu/.config/systemd/user/config-ninja.service; disabled; vendor preset: enabled)
     Active: active (running) since Sun 2024-01-21 22:37:52 EST; 7ms ago
    Process: 20240 ExecStartPre=/usr/local/bin/config-ninja self print (code=exited, status=0/SUCCESS)
   Main PID: 20241 (config-ninja)
     CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/config-ninja.service
             └─20241 /usr/local/bin/python /usr/local/bin/config-ninja monitor

Jan 21 22:37:51 ubuntu config-ninja[20240]:     path: /tmp/config-ninja/settings-subset.toml
Jan 21 22:37:51 ubuntu config-ninja[20240]:   source:
Jan 21 22:37:51 ubuntu config-ninja[20240]:     backend: local
Jan 21 22:37:51 ubuntu config-ninja[20240]:     format: yaml
Jan 21 22:37:51 ubuntu config-ninja[20240]:     new:
Jan 21 22:37:51 ubuntu config-ninja[20240]:       kwargs:
Jan 21 22:37:51 ubuntu config-ninja[20240]:         path: config-ninja-settings.yaml
Jan 21 22:37:52 ubuntu config-ninja[20241]: Begin monitoring: ['example-local', 'example-local-template', 'example-appconfig']
Jan 21 22:37:52 ubuntu systemd[592]: Started config synchronization daemon.

SUCCESS 
  1"""Generate a `systemd` unit file for installation as a service.
  2
  3The following `jinja2` template is used to generate the `systemd` unit file:
  4
  5```jinja
  6.. include:: ./templates/systemd.service.j2
  7```
  8
  9Run the CLI's `install`_ command to install the service:
 10
 11```sh
 12❯ config-ninja self install --env AWS_PROFILE --user
 13Installing /home/ubuntu/.config/systemd/user/config-ninja.service
 14● config-ninja.service - config synchronization daemon
 15     Loaded: loaded (/home/ubuntu/.config/systemd/user/config-ninja.service; disabled; vendor preset: enabled)
 16     Active: active (running) since Sun 2024-01-21 22:37:52 EST; 7ms ago
 17    Process: 20240 ExecStartPre=/usr/local/bin/config-ninja self print (code=exited, status=0/SUCCESS)
 18   Main PID: 20241 (config-ninja)
 19     CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/config-ninja.service
 20             └─20241 /usr/local/bin/python /usr/local/bin/config-ninja monitor
 21
 22Jan 21 22:37:51 ubuntu config-ninja[20240]:     path: /tmp/config-ninja/settings-subset.toml
 23Jan 21 22:37:51 ubuntu config-ninja[20240]:   source:
 24Jan 21 22:37:51 ubuntu config-ninja[20240]:     backend: local
 25Jan 21 22:37:51 ubuntu config-ninja[20240]:     format: yaml
 26Jan 21 22:37:51 ubuntu config-ninja[20240]:     new:
 27Jan 21 22:37:51 ubuntu config-ninja[20240]:       kwargs:
 28Jan 21 22:37:51 ubuntu config-ninja[20240]:         path: config-ninja-settings.yaml
 29Jan 21 22:37:52 ubuntu config-ninja[20241]: Begin monitoring: ['example-local', 'example-local-template', 'example-appconfig']
 30Jan 21 22:37:52 ubuntu systemd[592]: Started config synchronization daemon.
 31
 32SUCCESS ✅
 33```
 34
 35.. _install: https://bryant-finney.github.io/config-ninja/config_ninja/cli.html#config-ninja-self-install
 36"""  # noqa: RUF002
 37
 38from __future__ import annotations
 39
 40import contextlib
 41import logging
 42import os
 43import typing
 44from pathlib import Path
 45from typing import TYPE_CHECKING
 46
 47import jinja2
 48import sdnotify
 49
 50if TYPE_CHECKING:  # pragma: no cover
 51    import sh
 52
 53    AVAILABLE = True
 54else:
 55    try:
 56        import sh
 57    except ImportError:  # pragma: no cover
 58        sh = None
 59        AVAILABLE = False
 60    else:
 61        AVAILABLE = hasattr(sh, 'systemctl')
 62
 63
 64SERVICE_NAME = 'config-ninja.service'
 65SYSTEM_INSTALL_PATH = Path('/etc/systemd/system')
 66"""The file path for system-wide installation."""
 67
 68USER_INSTALL_PATH = Path(os.getenv('XDG_CONFIG_HOME') or Path.home() / '.config') / 'systemd' / 'user'
 69"""The file path for user-local installation."""
 70
 71__all__ = ['SYSTEM_INSTALL_PATH', 'USER_INSTALL_PATH', 'Service', 'notify']
 72logger = logging.getLogger(__name__)
 73
 74
 75@contextlib.contextmanager
 76def dummy() -> typing.Iterator[None]:
 77    """Define a dummy context manager to use instead of `sudo`.
 78
 79    There are a few scenarios where `sudo` is unavailable or unnecessary:
 80    - running on Windows
 81    - running in a container without `sudo` installed
 82    - already running as root
 83    """
 84    yield  # pragma: no cover
 85
 86
 87try:
 88    sudo = sh.contrib.sudo
 89except AttributeError:  # pragma: no cover
 90    sudo = dummy()
 91
 92
 93def notify() -> None:  # pragma: no cover
 94    """Notify `systemd` that the service has finished starting up and is ready."""
 95    sock = sdnotify.SystemdNotifier()
 96    sock.notify('READY=1')  # pyright: ignore[reportUnknownMemberType]
 97
 98
 99class Service:
100    """Manipulate the `systemd` service file for `config-ninja`.
101
102    ## User Installation
103
104    To install the service for only the current user, pass `user_mode=True` to the initializer:
105
106    >>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=True)
107    >>> _ = svc.install(
108    ...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
109    ... )
110
111    >>> print(svc.read())
112    [Unit]
113    Description=config synchronization daemon
114    After=network.target
115    <BLANKLINE>
116    [Service]
117    Environment=PYTHONUNBUFFERED=true
118    Environment=TESTING=true
119    ExecStartPre=config-ninja self print
120    ExecStart=config-ninja apply --poll
121    Restart=always
122    RestartSec=30s
123    Type=notify
124    WorkingDirectory=...
125    <BLANKLINE>
126    [Install]
127    Alias=config-ninja.service
128
129    >>> svc.uninstall()
130
131    ## System Installation
132
133    For system-wide installation:
134
135    >>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=False)
136    >>> _ = svc.install(
137    ...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
138    ... )
139
140    >>> svc.uninstall()
141    """
142
143    path: Path
144    """The installation location of the `systemd` unit file."""
145
146    sudo: typing.ContextManager[None]
147
148    tmpl: jinja2.Template
149    """Load the template on initialization."""
150
151    user_mode: bool
152    """Whether to install the service for the full system or just the current user."""
153
154    def __init__(self, provider: str, template: str, user_mode: bool) -> None:
155        """Prepare to render the specified `template` from the `provider` package."""
156        loader = jinja2.PackageLoader(provider)
157        env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader)
158        self.tmpl = env.get_template(template)
159        self.user_mode = user_mode
160        self.path = (USER_INSTALL_PATH if user_mode else SYSTEM_INSTALL_PATH) / SERVICE_NAME
161
162        if os.geteuid() == 0:
163            self.sudo = dummy()
164        else:
165            self.sudo = sudo
166
167    def _install_system(self, content: str) -> str:
168        logger.info('writing to %s', self.path)
169        sh.mkdir('-p', str(self.path.parent))
170        sh.tee(str(self.path), _in=content, _out='/dev/null')
171
172        logger.info('enabling and starting %s', self.path.name)
173        sh.systemctl.start(self.path.name)
174        return sh.systemctl.status(self.path.name)
175
176    def _install_user(self, content: str) -> str:
177        logger.info('writing to %s', self.path)
178        self.path.parent.mkdir(parents=True, exist_ok=True)
179        self.path.write_text(content, encoding='utf-8')
180
181        logger.info('enabling and starting %s', self.path.name)
182        sh.systemctl.start('--user', self.path.name)
183        return sh.systemctl.status('--user', self.path.name)
184
185    def _uninstall_system(self) -> None:
186        logger.info('stopping and disabling %s', self.path.name)
187        sh.systemctl.disable('--now', self.path.name)
188
189        logger.info('removing %s', self.path)
190        sh.rm(str(self.path))
191
192    def _uninstall_user(self) -> None:
193        logger.info('stopping and disabling %s', self.path.name)
194        sh.systemctl.disable('--user', '--now', self.path.name)
195
196        logger.info('removing %s', self.path)
197        self.path.unlink()
198
199    def install(self, **kwargs: typing.Any) -> str:
200        """Render the `systemd` service file from `kwargs` and install it."""
201        rendered = self.render(**kwargs)
202        if self.user_mode:
203            return self._install_user(rendered)
204
205        if os.geteuid() == 0:
206            return self._install_system(rendered)
207
208        with sudo:
209            return self._install_system(rendered)
210
211    def read(self) -> str:
212        """Read the `systemd` service file."""
213        return self.path.read_text(encoding='utf-8')
214
215    def render(self, **kwargs: typing.Any) -> str:
216        """Render the `systemd` service file from the given parameters."""
217        if workdir := kwargs.get('workdir'):
218            kwargs['workdir'] = Path(workdir).absolute()
219
220        kwargs.setdefault('user_mode', self.user_mode)
221
222        return self.tmpl.render(**kwargs)
223
224    def uninstall(self) -> None:
225        """Disable, stop, and delete the service."""
226        if self.user_mode:
227            return self._uninstall_user()
228
229        if os.geteuid() == 0:
230            return self._uninstall_system()
231
232        with sudo:
233            return self._uninstall_system()
SYSTEM_INSTALL_PATH = PosixPath('/etc/systemd/system')

The file path for system-wide installation.

USER_INSTALL_PATH = PosixPath('/home/docs/.config/systemd/user')

The file path for user-local installation.

class Service:
100class Service:
101    """Manipulate the `systemd` service file for `config-ninja`.
102
103    ## User Installation
104
105    To install the service for only the current user, pass `user_mode=True` to the initializer:
106
107    >>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=True)
108    >>> _ = svc.install(
109    ...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
110    ... )
111
112    >>> print(svc.read())
113    [Unit]
114    Description=config synchronization daemon
115    After=network.target
116    <BLANKLINE>
117    [Service]
118    Environment=PYTHONUNBUFFERED=true
119    Environment=TESTING=true
120    ExecStartPre=config-ninja self print
121    ExecStart=config-ninja apply --poll
122    Restart=always
123    RestartSec=30s
124    Type=notify
125    WorkingDirectory=...
126    <BLANKLINE>
127    [Install]
128    Alias=config-ninja.service
129
130    >>> svc.uninstall()
131
132    ## System Installation
133
134    For system-wide installation:
135
136    >>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=False)
137    >>> _ = svc.install(
138    ...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
139    ... )
140
141    >>> svc.uninstall()
142    """
143
144    path: Path
145    """The installation location of the `systemd` unit file."""
146
147    sudo: typing.ContextManager[None]
148
149    tmpl: jinja2.Template
150    """Load the template on initialization."""
151
152    user_mode: bool
153    """Whether to install the service for the full system or just the current user."""
154
155    def __init__(self, provider: str, template: str, user_mode: bool) -> None:
156        """Prepare to render the specified `template` from the `provider` package."""
157        loader = jinja2.PackageLoader(provider)
158        env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader)
159        self.tmpl = env.get_template(template)
160        self.user_mode = user_mode
161        self.path = (USER_INSTALL_PATH if user_mode else SYSTEM_INSTALL_PATH) / SERVICE_NAME
162
163        if os.geteuid() == 0:
164            self.sudo = dummy()
165        else:
166            self.sudo = sudo
167
168    def _install_system(self, content: str) -> str:
169        logger.info('writing to %s', self.path)
170        sh.mkdir('-p', str(self.path.parent))
171        sh.tee(str(self.path), _in=content, _out='/dev/null')
172
173        logger.info('enabling and starting %s', self.path.name)
174        sh.systemctl.start(self.path.name)
175        return sh.systemctl.status(self.path.name)
176
177    def _install_user(self, content: str) -> str:
178        logger.info('writing to %s', self.path)
179        self.path.parent.mkdir(parents=True, exist_ok=True)
180        self.path.write_text(content, encoding='utf-8')
181
182        logger.info('enabling and starting %s', self.path.name)
183        sh.systemctl.start('--user', self.path.name)
184        return sh.systemctl.status('--user', self.path.name)
185
186    def _uninstall_system(self) -> None:
187        logger.info('stopping and disabling %s', self.path.name)
188        sh.systemctl.disable('--now', self.path.name)
189
190        logger.info('removing %s', self.path)
191        sh.rm(str(self.path))
192
193    def _uninstall_user(self) -> None:
194        logger.info('stopping and disabling %s', self.path.name)
195        sh.systemctl.disable('--user', '--now', self.path.name)
196
197        logger.info('removing %s', self.path)
198        self.path.unlink()
199
200    def install(self, **kwargs: typing.Any) -> str:
201        """Render the `systemd` service file from `kwargs` and install it."""
202        rendered = self.render(**kwargs)
203        if self.user_mode:
204            return self._install_user(rendered)
205
206        if os.geteuid() == 0:
207            return self._install_system(rendered)
208
209        with sudo:
210            return self._install_system(rendered)
211
212    def read(self) -> str:
213        """Read the `systemd` service file."""
214        return self.path.read_text(encoding='utf-8')
215
216    def render(self, **kwargs: typing.Any) -> str:
217        """Render the `systemd` service file from the given parameters."""
218        if workdir := kwargs.get('workdir'):
219            kwargs['workdir'] = Path(workdir).absolute()
220
221        kwargs.setdefault('user_mode', self.user_mode)
222
223        return self.tmpl.render(**kwargs)
224
225    def uninstall(self) -> None:
226        """Disable, stop, and delete the service."""
227        if self.user_mode:
228            return self._uninstall_user()
229
230        if os.geteuid() == 0:
231            return self._uninstall_system()
232
233        with sudo:
234            return self._uninstall_system()

Manipulate the systemd service file for config-ninja.

User Installation

To install the service for only the current user, pass user_mode=True to the initializer:

>>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=True)
>>> _ = svc.install(
...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
... )
>>> print(svc.read())
[Unit]
Description=config synchronization daemon
After=network.target
<BLANKLINE>
[Service]
Environment=PYTHONUNBUFFERED=true
Environment=TESTING=true
ExecStartPre=config-ninja self print
ExecStart=config-ninja apply --poll
Restart=always
RestartSec=30s
Type=notify
WorkingDirectory=...
<BLANKLINE>
[Install]
Alias=config-ninja.service
>>> svc.uninstall()

System Installation

For system-wide installation:

>>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=False)
>>> _ = svc.install(
...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
... )
>>> svc.uninstall()
Service(provider: str, template: str, user_mode: bool)
155    def __init__(self, provider: str, template: str, user_mode: bool) -> None:
156        """Prepare to render the specified `template` from the `provider` package."""
157        loader = jinja2.PackageLoader(provider)
158        env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader)
159        self.tmpl = env.get_template(template)
160        self.user_mode = user_mode
161        self.path = (USER_INSTALL_PATH if user_mode else SYSTEM_INSTALL_PATH) / SERVICE_NAME
162
163        if os.geteuid() == 0:
164            self.sudo = dummy()
165        else:
166            self.sudo = sudo

Prepare to render the specified template from the provider package.

path: pathlib.Path

The installation location of the systemd unit file.

sudo: ContextManager[NoneType]

Load the template on initialization.

user_mode: bool

Whether to install the service for the full system or just the current user.

def install(self, **kwargs: Any) -> str:
200    def install(self, **kwargs: typing.Any) -> str:
201        """Render the `systemd` service file from `kwargs` and install it."""
202        rendered = self.render(**kwargs)
203        if self.user_mode:
204            return self._install_user(rendered)
205
206        if os.geteuid() == 0:
207            return self._install_system(rendered)
208
209        with sudo:
210            return self._install_system(rendered)

Render the systemd service file from kwargs and install it.

def read(self) -> str:
212    def read(self) -> str:
213        """Read the `systemd` service file."""
214        return self.path.read_text(encoding='utf-8')

Read the systemd service file.

def render(self, **kwargs: Any) -> str:
216    def render(self, **kwargs: typing.Any) -> str:
217        """Render the `systemd` service file from the given parameters."""
218        if workdir := kwargs.get('workdir'):
219            kwargs['workdir'] = Path(workdir).absolute()
220
221        kwargs.setdefault('user_mode', self.user_mode)
222
223        return self.tmpl.render(**kwargs)

Render the systemd service file from the given parameters.

def uninstall(self) -> None:
225    def uninstall(self) -> None:
226        """Disable, stop, and delete the service."""
227        if self.user_mode:
228            return self._uninstall_user()
229
230        if os.geteuid() == 0:
231            return self._uninstall_system()
232
233        with sudo:
234            return self._uninstall_system()

Disable, stop, and delete the service.

def notify() -> None:
94def notify() -> None:  # pragma: no cover
95    """Notify `systemd` that the service has finished starting up and is ready."""
96    sock = sdnotify.SystemdNotifier()
97    sock.notify('READY=1')  # pyright: ignore[reportUnknownMemberType]

Notify systemd that the service has finished starting up and is ready.