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 {{ 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 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()
The file path for system-wide installation.
The file path for user-local installation.
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()
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.
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.
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.
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.
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.
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.