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
 52else:
 53    try:
 54        import sh
 55    except ImportError:  # pragma: no cover
 56        sh = None
 57
 58SERVICE_NAME = 'config-ninja.service'
 59SYSTEM_INSTALL_PATH = Path('/etc/systemd/system')
 60"""The file path for system-wide installation."""
 61
 62USER_INSTALL_PATH = Path(os.getenv('XDG_CONFIG_HOME') or Path.home() / '.config') / 'systemd' / 'user'
 63"""The file path for user-local installation."""
 64
 65__all__ = ['SYSTEM_INSTALL_PATH', 'USER_INSTALL_PATH', 'Service', 'notify']
 66logger = logging.getLogger(__name__)
 67
 68
 69@contextlib.contextmanager
 70def dummy() -> typing.Iterator[None]:
 71    """Define a dummy context manager to use instead of `sudo`.
 72
 73    There are a few scenarios where `sudo` is unavailable or unnecessary:
 74    - running on Windows
 75    - running in a container without `sudo` installed
 76    - already running as root
 77    """
 78    yield  # pragma: no cover
 79
 80
 81try:
 82    sudo = sh.contrib.sudo
 83except AttributeError:  # pragma: no cover
 84    sudo = dummy()
 85
 86
 87def notify() -> None:  # pragma: no cover
 88    """Notify `systemd` that the service has finished starting up and is ready."""
 89    sock = sdnotify.SystemdNotifier()
 90    sock.notify('READY=1')  # pyright: ignore[reportUnknownMemberType]
 91
 92
 93class Service:
 94    """Manipulate the `systemd` service file for `config-ninja`.
 95
 96    ## User Installation
 97
 98    To install the service for only the current user, pass `user_mode=True` to the initializer:
 99
100    >>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=True)
101    >>> _ = svc.install(
102    ...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
103    ... )
104
105    >>> print(svc.read())
106    [Unit]
107    Description=config synchronization daemon
108    After=network.target
109    <BLANKLINE>
110    [Service]
111    Environment=PYTHONUNBUFFERED=true
112    Environment=TESTING=true
113    ExecStartPre=config-ninja self print
114    ExecStart=config-ninja apply --poll
115    Restart=always
116    RestartSec=30s
117    Type=notify
118    WorkingDirectory=...
119    <BLANKLINE>
120    [Install]
121    Alias=config-ninja.service
122
123    >>> svc.uninstall()
124
125    ## System Installation
126
127    For system-wide installation:
128
129    >>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=False)
130    >>> _ = svc.install(
131    ...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
132    ... )
133
134    >>> svc.uninstall()
135    """
136
137    path: Path
138    """The installation location of the `systemd` unit file."""
139
140    sudo: typing.ContextManager[None]
141
142    tmpl: jinja2.Template
143    """Load the template on initialization."""
144
145    user_mode: bool
146    """Whether to install the service for the full system or just the current user."""
147
148    def __init__(self, provider: str, template: str, user_mode: bool) -> None:
149        """Prepare to render the specified `template` from the `provider` package."""
150        loader = jinja2.PackageLoader(provider)
151        env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader)
152        self.tmpl = env.get_template(template)
153        self.user_mode = user_mode
154        self.path = (USER_INSTALL_PATH if user_mode else SYSTEM_INSTALL_PATH) / SERVICE_NAME
155
156        if os.geteuid() == 0:
157            self.sudo = dummy()
158        else:
159            self.sudo = sudo
160
161    def _install_system(self, content: str) -> str:
162        logger.info('writing to %s', self.path)
163        sh.mkdir('-p', str(self.path.parent))
164        sh.tee(str(self.path), _in=content, _out='/dev/null')
165
166        logger.info('enabling and starting %s', self.path.name)
167        sh.systemctl.start(self.path.name)
168        return sh.systemctl.status(self.path.name)
169
170    def _install_user(self, content: str) -> str:
171        logger.info('writing to %s', self.path)
172        self.path.parent.mkdir(parents=True, exist_ok=True)
173        self.path.write_text(content, encoding='utf-8')
174
175        logger.info('enabling and starting %s', self.path.name)
176        sh.systemctl.start('--user', self.path.name)
177        return sh.systemctl.status('--user', self.path.name)
178
179    def _uninstall_system(self) -> None:
180        logger.info('stopping and disabling %s', self.path.name)
181        sh.systemctl.disable('--now', self.path.name)
182
183        logger.info('removing %s', self.path)
184        sh.rm(str(self.path))
185
186    def _uninstall_user(self) -> None:
187        logger.info('stopping and disabling %s', self.path.name)
188        sh.systemctl.disable('--user', '--now', self.path.name)
189
190        logger.info('removing %s', self.path)
191        self.path.unlink()
192
193    def install(self, **kwargs: typing.Any) -> str:
194        """Render the `systemd` service file from `kwargs` and install it."""
195        rendered = self.render(**kwargs)
196        if self.user_mode:
197            return self._install_user(rendered)
198
199        if os.geteuid() == 0:
200            return self._install_system(rendered)
201
202        with sudo:
203            return self._install_system(rendered)
204
205    def read(self) -> str:
206        """Read the `systemd` service file."""
207        return self.path.read_text(encoding='utf-8')
208
209    def render(self, **kwargs: typing.Any) -> str:
210        """Render the `systemd` service file from the given parameters."""
211        if workdir := kwargs.get('workdir'):
212            kwargs['workdir'] = Path(workdir).absolute()
213
214        kwargs.setdefault('user_mode', self.user_mode)
215
216        return self.tmpl.render(**kwargs)
217
218    def uninstall(self) -> None:
219        """Disable, stop, and delete the service."""
220        if self.user_mode:
221            return self._uninstall_user()
222
223        if os.geteuid() == 0:
224            return self._uninstall_system()
225
226        with sudo:
227            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:
 94class Service:
 95    """Manipulate the `systemd` service file for `config-ninja`.
 96
 97    ## User Installation
 98
 99    To install the service for only the current user, pass `user_mode=True` to the initializer:
100
101    >>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=True)
102    >>> _ = svc.install(
103    ...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
104    ... )
105
106    >>> print(svc.read())
107    [Unit]
108    Description=config synchronization daemon
109    After=network.target
110    <BLANKLINE>
111    [Service]
112    Environment=PYTHONUNBUFFERED=true
113    Environment=TESTING=true
114    ExecStartPre=config-ninja self print
115    ExecStart=config-ninja apply --poll
116    Restart=always
117    RestartSec=30s
118    Type=notify
119    WorkingDirectory=...
120    <BLANKLINE>
121    [Install]
122    Alias=config-ninja.service
123
124    >>> svc.uninstall()
125
126    ## System Installation
127
128    For system-wide installation:
129
130    >>> svc = Service('config_ninja', 'systemd.service.j2', user_mode=False)
131    >>> _ = svc.install(
132    ...     config_ninja_cmd='config-ninja', workdir='.', environ={'TESTING': 'true'}
133    ... )
134
135    >>> svc.uninstall()
136    """
137
138    path: Path
139    """The installation location of the `systemd` unit file."""
140
141    sudo: typing.ContextManager[None]
142
143    tmpl: jinja2.Template
144    """Load the template on initialization."""
145
146    user_mode: bool
147    """Whether to install the service for the full system or just the current user."""
148
149    def __init__(self, provider: str, template: str, user_mode: bool) -> None:
150        """Prepare to render the specified `template` from the `provider` package."""
151        loader = jinja2.PackageLoader(provider)
152        env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader)
153        self.tmpl = env.get_template(template)
154        self.user_mode = user_mode
155        self.path = (USER_INSTALL_PATH if user_mode else SYSTEM_INSTALL_PATH) / SERVICE_NAME
156
157        if os.geteuid() == 0:
158            self.sudo = dummy()
159        else:
160            self.sudo = sudo
161
162    def _install_system(self, content: str) -> str:
163        logger.info('writing to %s', self.path)
164        sh.mkdir('-p', str(self.path.parent))
165        sh.tee(str(self.path), _in=content, _out='/dev/null')
166
167        logger.info('enabling and starting %s', self.path.name)
168        sh.systemctl.start(self.path.name)
169        return sh.systemctl.status(self.path.name)
170
171    def _install_user(self, content: str) -> str:
172        logger.info('writing to %s', self.path)
173        self.path.parent.mkdir(parents=True, exist_ok=True)
174        self.path.write_text(content, encoding='utf-8')
175
176        logger.info('enabling and starting %s', self.path.name)
177        sh.systemctl.start('--user', self.path.name)
178        return sh.systemctl.status('--user', self.path.name)
179
180    def _uninstall_system(self) -> None:
181        logger.info('stopping and disabling %s', self.path.name)
182        sh.systemctl.disable('--now', self.path.name)
183
184        logger.info('removing %s', self.path)
185        sh.rm(str(self.path))
186
187    def _uninstall_user(self) -> None:
188        logger.info('stopping and disabling %s', self.path.name)
189        sh.systemctl.disable('--user', '--now', self.path.name)
190
191        logger.info('removing %s', self.path)
192        self.path.unlink()
193
194    def install(self, **kwargs: typing.Any) -> str:
195        """Render the `systemd` service file from `kwargs` and install it."""
196        rendered = self.render(**kwargs)
197        if self.user_mode:
198            return self._install_user(rendered)
199
200        if os.geteuid() == 0:
201            return self._install_system(rendered)
202
203        with sudo:
204            return self._install_system(rendered)
205
206    def read(self) -> str:
207        """Read the `systemd` service file."""
208        return self.path.read_text(encoding='utf-8')
209
210    def render(self, **kwargs: typing.Any) -> str:
211        """Render the `systemd` service file from the given parameters."""
212        if workdir := kwargs.get('workdir'):
213            kwargs['workdir'] = Path(workdir).absolute()
214
215        kwargs.setdefault('user_mode', self.user_mode)
216
217        return self.tmpl.render(**kwargs)
218
219    def uninstall(self) -> None:
220        """Disable, stop, and delete the service."""
221        if self.user_mode:
222            return self._uninstall_user()
223
224        if os.geteuid() == 0:
225            return self._uninstall_system()
226
227        with sudo:
228            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)
149    def __init__(self, provider: str, template: str, user_mode: bool) -> None:
150        """Prepare to render the specified `template` from the `provider` package."""
151        loader = jinja2.PackageLoader(provider)
152        env = jinja2.Environment(autoescape=jinja2.select_autoescape(default=True), loader=loader)
153        self.tmpl = env.get_template(template)
154        self.user_mode = user_mode
155        self.path = (USER_INSTALL_PATH if user_mode else SYSTEM_INSTALL_PATH) / SERVICE_NAME
156
157        if os.geteuid() == 0:
158            self.sudo = dummy()
159        else:
160            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:
194    def install(self, **kwargs: typing.Any) -> str:
195        """Render the `systemd` service file from `kwargs` and install it."""
196        rendered = self.render(**kwargs)
197        if self.user_mode:
198            return self._install_user(rendered)
199
200        if os.geteuid() == 0:
201            return self._install_system(rendered)
202
203        with sudo:
204            return self._install_system(rendered)

Render the systemd service file from kwargs and install it.

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

Read the systemd service file.

def render(self, **kwargs: Any) -> str:
210    def render(self, **kwargs: typing.Any) -> str:
211        """Render the `systemd` service file from the given parameters."""
212        if workdir := kwargs.get('workdir'):
213            kwargs['workdir'] = Path(workdir).absolute()
214
215        kwargs.setdefault('user_mode', self.user_mode)
216
217        return self.tmpl.render(**kwargs)

Render the systemd service file from the given parameters.

def uninstall(self) -> None:
219    def uninstall(self) -> None:
220        """Disable, stop, and delete the service."""
221        if self.user_mode:
222            return self._uninstall_user()
223
224        if os.geteuid() == 0:
225            return self._uninstall_system()
226
227        with sudo:
228            return self._uninstall_system()

Disable, stop, and delete the service.

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

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