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

valid_chars: str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-:'

Valid characters for the systemd unit file name.

max_length: int = 255

Maximum length of the systemd unit file name.

def install(self, **kwargs: Any) -> str:
220    def install(self, **kwargs: typing.Any) -> str:
221        """Render the `systemd` service file from `kwargs` and install it."""
222        rendered = self.render(**kwargs)
223        if self.user_mode:
224            return self._install_user(rendered)
225
226        if os.geteuid() == 0:
227            return self._install_system(rendered)
228
229        with sudo:
230            return self._install_system(rendered)

Render the systemd service file from kwargs and install it.

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

Read the systemd service file.

def render(self, **kwargs: Any) -> str:
236    def render(self, **kwargs: typing.Any) -> str:
237        """Render the `systemd` service file from the given parameters."""
238        if workdir := kwargs.get('workdir'):
239            kwargs['workdir'] = Path(workdir).absolute()
240
241        kwargs.setdefault('user_mode', self.user_mode)
242
243        return self.tmpl.render(**kwargs)

Render the systemd service file from the given parameters.

def uninstall(self) -> None:
245    def uninstall(self) -> None:
246        """Disable, stop, and delete the service."""
247        if self.user_mode:
248            return self._uninstall_user()
249
250        if os.geteuid() == 0:
251            return self._uninstall_system()
252
253        with sudo:
254            return self._uninstall_system()

Disable, stop, and delete the service.

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

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