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