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