systemd-pydantic 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ from .client import *
2
+ from .config import *
3
+ from .convenience import *
4
+ from .models import (
5
+ InstallConfiguration,
6
+ KillMode,
7
+ RestartPolicy,
8
+ ServiceConfiguration,
9
+ ServiceType,
10
+ ServiceUnitConfiguration,
11
+ TimerConfiguration,
12
+ TimerUnitConfiguration,
13
+ TimeSpan,
14
+ UnitConfiguration,
15
+ )
16
+
17
+ __version__ = "0.1.0"
@@ -0,0 +1,19 @@
1
+ from .systemctl import (
2
+ CommandResult,
3
+ CommandRunner,
4
+ SSHCommandRunner,
5
+ SubprocessCommandRunner,
6
+ SystemdClient,
7
+ SystemdCommandError,
8
+ UnitInfo,
9
+ )
10
+
11
+ __all__ = (
12
+ "CommandResult",
13
+ "CommandRunner",
14
+ "SSHCommandRunner",
15
+ "SubprocessCommandRunner",
16
+ "SystemdClient",
17
+ "SystemdCommandError",
18
+ "UnitInfo",
19
+ )
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from subprocess import run
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+ if TYPE_CHECKING:
10
+ from ..config import SystemdConfiguration
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CommandResult:
15
+ command: list[str]
16
+ returncode: int
17
+ stdout: str = ""
18
+ stderr: str = ""
19
+
20
+
21
+ class CommandRunner(Protocol):
22
+ def run(self, command: list[str], timeout: int) -> CommandResult: ...
23
+
24
+
25
+ class SubprocessCommandRunner:
26
+ def run(self, command: list[str], timeout: int) -> CommandResult:
27
+ result = run(command, capture_output=True, text=True, timeout=timeout, check=False)
28
+ return CommandResult(command=command, returncode=result.returncode, stdout=result.stdout, stderr=result.stderr)
29
+
30
+
31
+ class SSHCommandRunner:
32
+ def __init__(self, host: str, runner: CommandRunner | None = None):
33
+ self.host = host
34
+ self.runner = runner or SubprocessCommandRunner()
35
+
36
+ def run(self, command: list[str], timeout: int) -> CommandResult:
37
+ return self.runner.run(["ssh", self.host, *command], timeout)
38
+
39
+
40
+ class SystemdCommandError(RuntimeError):
41
+ def __init__(self, result: CommandResult):
42
+ self.result = result
43
+ detail = result.stderr.strip() or result.stdout.strip()
44
+ super().__init__(f"command failed ({result.returncode}): {' '.join(result.command)}{': ' + detail if detail else ''}")
45
+
46
+
47
+ class UnitInfo(BaseModel):
48
+ model_config = ConfigDict(extra="ignore")
49
+
50
+ name: str
51
+ load_state: str = ""
52
+ active_state: str = ""
53
+ sub_state: str = ""
54
+ result: str = ""
55
+ exec_main_code: str = ""
56
+ exec_main_status: int | None = None
57
+
58
+ def running(self) -> bool:
59
+ return self.active_state in {"active", "activating", "reloading"}
60
+
61
+ def stopped(self) -> bool:
62
+ return self.active_state in {"inactive", "deactivating"}
63
+
64
+ def done(self, ok_exitstatuses: list[int | str] | None = None) -> bool:
65
+ statuses = {str(value) for value in (ok_exitstatuses or [0])}
66
+ return self.stopped() and self.result in {"", "success"} and str(self.exec_main_status or 0) in statuses
67
+
68
+ def ok(self, ok_exitstatuses: list[int | str] | None = None) -> bool:
69
+ return self.running() or self.done(ok_exitstatuses)
70
+
71
+ def bad(self, ok_exitstatuses: list[int | str] | None = None) -> bool:
72
+ return self.active_state == "failed" or (self.stopped() and not self.done(ok_exitstatuses))
73
+
74
+
75
+ class SystemdClient:
76
+ _SHOW_PROPERTIES = ("LoadState", "ActiveState", "SubState", "Result", "ExecMainCode", "ExecMainStatus")
77
+
78
+ def __init__(self, cfg: SystemdConfiguration, runner: CommandRunner | None = None):
79
+ self.cfg = cfg
80
+ self.runner = runner or SubprocessCommandRunner()
81
+ self.timeout = getattr(cfg, "command_timeout", 60)
82
+
83
+ def _run(self, *args: str, check: bool = True) -> CommandResult:
84
+ command = ["systemctl"]
85
+ if self.cfg.scope == "user":
86
+ command.append("--user")
87
+ result = self.runner.run([*command, *args], self.timeout)
88
+ if check and result.returncode != 0:
89
+ raise SystemdCommandError(result)
90
+ return result
91
+
92
+ def daemon_reload(self) -> CommandResult:
93
+ return self._run("daemon-reload")
94
+
95
+ def enable_units(self, names: list[str] | None = None) -> CommandResult:
96
+ return self._run("enable", *(names if names is not None else [*self.cfg.service_names, *self.cfg.timer_names]))
97
+
98
+ def disable_units(self, names: list[str] | None = None) -> CommandResult:
99
+ return self._run("disable", *(names if names is not None else [*self.cfg.service_names, *self.cfg.timer_names]))
100
+
101
+ def start_units(self, names: list[str]) -> dict[str, UnitInfo]:
102
+ if names:
103
+ self._run("start", *names)
104
+ return self.get_units_info(names)
105
+
106
+ def stop_units(self, names: list[str]) -> dict[str, UnitInfo]:
107
+ if names:
108
+ self._run("stop", *names)
109
+ return self.get_units_info(names)
110
+
111
+ def restart_units(self, names: list[str]) -> dict[str, UnitInfo]:
112
+ if names:
113
+ self._run("restart", *names)
114
+ return self.get_units_info(names)
115
+
116
+ def kill_units(self, names: list[str]) -> dict[str, UnitInfo]:
117
+ if names:
118
+ self._run("kill", "--signal=SIGKILL", *names)
119
+ return self.get_units_info(names)
120
+
121
+ def start_services(self) -> dict[str, UnitInfo]:
122
+ return self.start_units(self.cfg.service_names)
123
+
124
+ def stop_services(self) -> dict[str, UnitInfo]:
125
+ return self.stop_units(self.cfg.service_names)
126
+
127
+ def restart_services(self) -> dict[str, UnitInfo]:
128
+ return self.restart_units(self.cfg.service_names)
129
+
130
+ def kill_services(self) -> dict[str, UnitInfo]:
131
+ return self.kill_units(self.cfg.service_names)
132
+
133
+ def start_timers(self) -> dict[str, UnitInfo]:
134
+ return self.start_units(self.cfg.timer_names)
135
+
136
+ def stop_timers(self) -> dict[str, UnitInfo]:
137
+ return self.stop_units(self.cfg.timer_names)
138
+
139
+ def get_unit_info(self, name: str) -> UnitInfo:
140
+ properties = [f"--property={value}" for value in self._SHOW_PROPERTIES]
141
+ result = self._run("show", name, *properties, check=False)
142
+ values = {}
143
+ for line in result.stdout.splitlines():
144
+ key, separator, value = line.partition("=")
145
+ if separator:
146
+ values[key] = value
147
+ status = values.get("ExecMainStatus", "")
148
+ return UnitInfo(
149
+ name=name,
150
+ load_state=values.get("LoadState", "not-found" if result.returncode else ""),
151
+ active_state=values.get("ActiveState", "failed" if result.returncode else ""),
152
+ sub_state=values.get("SubState", ""),
153
+ result=values.get("Result", ""),
154
+ exec_main_code=values.get("ExecMainCode", ""),
155
+ exec_main_status=int(status) if status.lstrip("-").isdigit() else None,
156
+ )
157
+
158
+ def get_units_info(self, names: list[str]) -> dict[str, UnitInfo]:
159
+ return {name: self.get_unit_info(name) for name in names}
160
+
161
+ def get_all_service_info(self) -> dict[str, UnitInfo]:
162
+ return self.get_units_info(self.cfg.service_names)
163
+
164
+ def get_all_timer_info(self) -> dict[str, UnitInfo]:
165
+ return self.get_units_info(self.cfg.timer_names)
@@ -0,0 +1,10 @@
1
+ from .convenience import SystemdConvenienceConfiguration, load_convenience_config
2
+ from .systemd import SystemdConfiguration, SystemdScope, load_config
3
+
4
+ __all__ = (
5
+ "SystemdConfiguration",
6
+ "SystemdConvenienceConfiguration",
7
+ "SystemdScope",
8
+ "load_config",
9
+ "load_convenience_config",
10
+ )
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import Field, PrivateAttr, model_validator
4
+
5
+ from ..models import KillMode, RestartPolicy, TimeSpan
6
+ from .systemd import SystemdConfiguration
7
+
8
+
9
+ class SystemdConvenienceConfiguration(SystemdConfiguration):
10
+ """Systemd defaults and persisted state used by convenience commands."""
11
+
12
+ _pydantic_path: Path = PrivateAttr(default=Path("pydantic.json"))
13
+
14
+ restart: RestartPolicy = "no"
15
+ timeout_stop_sec: TimeSpan = "30s"
16
+ kill_mode: KillMode = "control-group"
17
+ kill_signal: str = "SIGTERM"
18
+ success_exit_status: list[int | str] = [0]
19
+ command_timeout: int = Field(default=60, ge=1)
20
+
21
+ @model_validator(mode="after")
22
+ def _set_convenience_defaults(self) -> "SystemdConvenienceConfiguration":
23
+ for unit in self.service.values():
24
+ service = unit.service
25
+ service.restart = service.restart or self.restart
26
+ service.timeout_stop_sec = service.timeout_stop_sec or self.timeout_stop_sec
27
+ service.kill_mode = service.kill_mode or self.kill_mode
28
+ service.kill_signal = service.kill_signal or self.kill_signal
29
+ service.success_exit_status = service.success_exit_status or self.success_exit_status
30
+ assert self.working_dir is not None
31
+ self._pydantic_path = self.working_dir / "pydantic.json"
32
+ return self
33
+
34
+ def _write_self(self) -> None:
35
+ assert self.working_dir is not None
36
+ self.working_dir.mkdir(parents=True, exist_ok=True)
37
+ self.write()
38
+ self._pydantic_path.write_text(self.model_dump_json(exclude_unset=True))
39
+
40
+ def rmdir(self) -> None:
41
+ self._pydantic_path.unlink(missing_ok=True)
42
+ super().rmdir()
43
+
44
+
45
+ load_convenience_config = SystemdConvenienceConfiguration.load
@@ -0,0 +1,5 @@
1
+ # @package _global_
2
+
3
+ _target_: systemd_pydantic.SystemdConfiguration
4
+ service: {}
5
+ timer: {}
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import os
5
+ from getpass import getuser
6
+ from pathlib import Path
7
+ from tempfile import gettempdir
8
+ from typing import TYPE_CHECKING, Literal, Self
9
+
10
+ from hydra import compose, initialize_config_dir
11
+ from hydra.utils import instantiate
12
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
13
+
14
+ from ..models import ServiceUnitConfiguration, TimerUnitConfiguration
15
+
16
+ SystemdScope = Literal["system", "user"]
17
+
18
+ if TYPE_CHECKING:
19
+ from ..client import UnitInfo
20
+
21
+
22
+ def _calling_file(offset: int) -> str:
23
+ return inspect.stack()[offset].filename
24
+
25
+
26
+ def _unit_filename(name: str, suffix: str) -> str:
27
+ if "/" in name or name in {"", ".", ".."}:
28
+ raise ValueError(f"invalid unit name: {name!r}")
29
+ if name.endswith(suffix):
30
+ return name
31
+ if "." in name:
32
+ raise ValueError(f"unit {name!r} must end in {suffix}")
33
+ return f"{name}{suffix}"
34
+
35
+
36
+ class SystemdConfiguration(BaseModel):
37
+ """Named collection of systemd service and timer unit files."""
38
+
39
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
40
+
41
+ service: dict[str, ServiceUnitConfiguration]
42
+ timer: dict[str, TimerUnitConfiguration] = Field(default_factory=dict)
43
+ unit_dir: Path | None = None
44
+ working_dir: Path | None = None
45
+ scope: SystemdScope = "system"
46
+
47
+ @model_validator(mode="after")
48
+ def _set_paths(self) -> SystemdConfiguration:
49
+ names = sorted([*self.service, *self.timer])
50
+ label = "-".join(names) or "units"
51
+ if self.working_dir is None:
52
+ object.__setattr__(self, "working_dir", Path(gettempdir()).resolve() / f"systemd-{getuser()}-{label}")
53
+ if self.unit_dir is None:
54
+ object.__setattr__(
55
+ self,
56
+ "unit_dir",
57
+ Path("/etc/systemd/system") if self.scope == "system" else Path.home() / ".config/systemd/user",
58
+ )
59
+ for name in self.service:
60
+ _unit_filename(name, ".service")
61
+ for name in self.timer:
62
+ _unit_filename(name, ".timer")
63
+ return self
64
+
65
+ def to_cfg(self) -> dict[str, str]:
66
+ rendered = {_unit_filename(name, ".service"): config.to_cfg() for name, config in self.service.items()}
67
+ rendered.update({_unit_filename(name, ".timer"): config.to_cfg() for name, config in self.timer.items()})
68
+ return rendered
69
+
70
+ @property
71
+ def service_names(self) -> list[str]:
72
+ return [_unit_filename(name, ".service") for name in self.service]
73
+
74
+ @property
75
+ def timer_names(self) -> list[str]:
76
+ return [_unit_filename(name, ".timer") for name in self.timer]
77
+
78
+ @property
79
+ def unit_paths(self) -> list[Path]:
80
+ assert self.unit_dir is not None
81
+ return [self.unit_dir / name for name in self.to_cfg()]
82
+
83
+ def write(self) -> list[Path]:
84
+ assert self.unit_dir is not None
85
+ self.unit_dir.mkdir(parents=True, exist_ok=True)
86
+ paths = []
87
+ for filename, contents in self.to_cfg().items():
88
+ path = self.unit_dir / filename
89
+ path.write_text(contents)
90
+ paths.append(path)
91
+ return paths
92
+
93
+ def rmdir(self) -> None:
94
+ for path in self.unit_paths:
95
+ path.unlink(missing_ok=True)
96
+ assert self.working_dir is not None
97
+ if self.working_dir.exists() and not any(self.working_dir.iterdir()):
98
+ self.working_dir.rmdir()
99
+
100
+ @classmethod
101
+ def _find_parent_config_folder(
102
+ cls,
103
+ config_dir: str = "config",
104
+ config_name: str = "",
105
+ *,
106
+ basepath: str = "",
107
+ _offset: int = 2,
108
+ ) -> tuple[Path, Path, Path | str]:
109
+ if basepath:
110
+ calling_file = Path(basepath) if basepath.endswith((".py", ".yml", ".yaml")) else Path(basepath) / "dummy.py"
111
+ else:
112
+ calling_file = Path(_calling_file(_offset))
113
+ folder = calling_file.parent.resolve()
114
+
115
+ while True:
116
+ candidate = folder / config_dir
117
+ if not config_name and candidate.exists():
118
+ return folder, candidate.resolve(), ""
119
+ for suffix in (".yml", ".yaml"):
120
+ path = candidate / f"{config_name}{suffix}"
121
+ if config_name and path.exists():
122
+ return folder, candidate.resolve(), path.resolve()
123
+ if str(folder) == os.path.abspath(os.sep):
124
+ raise FileNotFoundError(f"could not find {config_name or config_dir!r} from {calling_file}")
125
+ folder = folder.parent
126
+
127
+ @classmethod
128
+ def load(
129
+ cls,
130
+ config_dir: str = "config",
131
+ config_name: str = "",
132
+ overrides: list[str] | None = None,
133
+ *,
134
+ basepath: str = "",
135
+ _offset: int = 3,
136
+ ) -> Self:
137
+ overrides = overrides or []
138
+ hydra_dir = Path(__file__).resolve().parent / "hydra"
139
+ with initialize_config_dir(config_dir=str(hydra_dir), version_base=None):
140
+ if config_dir:
141
+ hydra_folder, resolved_config_dir, _ = cls._find_parent_config_folder(
142
+ config_dir=config_dir,
143
+ config_name=config_name,
144
+ basepath=basepath,
145
+ _offset=_offset,
146
+ )
147
+ base = compose(config_name="base", return_hydra_config=True)
148
+ searchpaths = list(base["hydra"]["searchpath"])
149
+ searchpaths.extend([str(hydra_folder), str(resolved_config_dir)])
150
+ if config_name:
151
+ overrides = [f"+config={config_name}", *overrides, f"hydra.searchpath=[{','.join(searchpaths)}]"]
152
+ else:
153
+ overrides = [*overrides, f"hydra.searchpath=[{','.join(searchpaths)}]"]
154
+ config = instantiate(compose(config_name="base", overrides=overrides))
155
+ if isinstance(config, cls):
156
+ return config
157
+ if isinstance(config, BaseModel):
158
+ config = config.model_dump(exclude_unset=True)
159
+ return cls.model_validate(config)
160
+
161
+ def start(self) -> dict[str, UnitInfo]:
162
+ from ..client import SystemdClient
163
+
164
+ return SystemdClient(self).start_services()
165
+
166
+ def running(self) -> bool:
167
+ from ..client import SystemdClient
168
+
169
+ return all(info.running() for info in SystemdClient(self).get_all_service_info().values())
170
+
171
+ def stop(self) -> dict[str, UnitInfo]:
172
+ from ..client import SystemdClient
173
+
174
+ return SystemdClient(self).stop_services()
175
+
176
+ def kill(self) -> dict[str, UnitInfo]:
177
+ from ..client import SystemdClient
178
+
179
+ return SystemdClient(self).kill_services()
180
+
181
+
182
+ load_config = SystemdConfiguration.load
@@ -0,0 +1,21 @@
1
+ from .commands import (
2
+ check_services,
3
+ main,
4
+ remove_systemd_config,
5
+ restart_services,
6
+ start_services,
7
+ stop_services,
8
+ write_systemd_config,
9
+ )
10
+ from .common import SystemdTaskStep
11
+
12
+ __all__ = (
13
+ "SystemdTaskStep",
14
+ "check_services",
15
+ "main",
16
+ "remove_systemd_config",
17
+ "restart_services",
18
+ "start_services",
19
+ "stop_services",
20
+ "write_systemd_config",
21
+ )
@@ -0,0 +1,109 @@
1
+ from pathlib import Path
2
+
3
+ from typer import Argument, Exit, Option, Typer
4
+ from typing_extensions import Annotated
5
+
6
+ from ..client import SystemdClient
7
+ from ..config import SystemdConvenienceConfiguration
8
+ from .common import SystemdTaskStep
9
+
10
+ ConfigInput = str | Path | SystemdConvenienceConfiguration
11
+
12
+
13
+ def _raise_or_exit(value: bool, exit: bool):
14
+ if exit:
15
+ raise Exit(int(not value))
16
+ return value
17
+
18
+
19
+ def _load_or_pass(cfg: ConfigInput) -> SystemdConvenienceConfiguration:
20
+ if isinstance(cfg, Path):
21
+ cfg = cfg.read_text()
22
+ if isinstance(cfg, str):
23
+ return SystemdConvenienceConfiguration.model_validate_json(cfg)
24
+ if not isinstance(cfg, SystemdConvenienceConfiguration):
25
+ raise NotImplementedError
26
+ return cfg
27
+
28
+
29
+ def write_systemd_config(cfg_json: str, _exit: Annotated[bool, Argument(hidden=True)] = True):
30
+ cfg = _load_or_pass(cfg_json)
31
+ cfg._write_self()
32
+ client = SystemdClient(cfg)
33
+ client.daemon_reload()
34
+ return _raise_or_exit(True, _exit)
35
+
36
+
37
+ def start_services(
38
+ cfg: Annotated[Path, Option(exists=True, file_okay=True, dir_okay=False, readable=True)] = Path("pydantic.json"),
39
+ restart: bool = False,
40
+ _exit: Annotated[bool, Argument(hidden=True)] = True,
41
+ ):
42
+ config = _load_or_pass(cfg)
43
+ client = SystemdClient(config)
44
+ result = client.restart_services() if restart else client.start_services()
45
+ ok = all(info.ok(config.success_exit_status) for info in result.values())
46
+ return _raise_or_exit(ok, _exit)
47
+
48
+
49
+ def check_services(
50
+ cfg: Annotated[Path, Option(exists=True, file_okay=True, dir_okay=False, readable=True)] = Path("pydantic.json"),
51
+ check_running: bool = False,
52
+ check_done: bool = False,
53
+ _exit: Annotated[bool, Argument(hidden=True)] = True,
54
+ ):
55
+ config = _load_or_pass(cfg)
56
+ infos = SystemdClient(config).get_all_service_info().values()
57
+ if check_running:
58
+ ok = all(info.running() for info in infos)
59
+ elif check_done:
60
+ ok = all(info.done(config.success_exit_status) for info in infos)
61
+ else:
62
+ ok = all(info.ok(config.success_exit_status) for info in infos)
63
+ return _raise_or_exit(ok, _exit)
64
+
65
+
66
+ def stop_services(
67
+ cfg: Annotated[Path, Option(exists=True, file_okay=True, dir_okay=False, readable=True)] = Path("pydantic.json"),
68
+ _exit: Annotated[bool, Argument(hidden=True)] = True,
69
+ ):
70
+ config = _load_or_pass(cfg)
71
+ infos = SystemdClient(config).stop_services().values()
72
+ return _raise_or_exit(all(info.stopped() for info in infos), _exit)
73
+
74
+
75
+ def restart_services(
76
+ cfg: Annotated[Path, Option(exists=True, file_okay=True, dir_okay=False, readable=True)] = Path("pydantic.json"),
77
+ _exit: Annotated[bool, Argument(hidden=True)] = True,
78
+ ):
79
+ config = _load_or_pass(cfg)
80
+ infos = SystemdClient(config).restart_services().values()
81
+ return _raise_or_exit(all(info.ok(config.success_exit_status) for info in infos), _exit)
82
+
83
+
84
+ def remove_systemd_config(
85
+ cfg: Annotated[Path, Option(exists=True, file_okay=True, dir_okay=False, readable=True)] = Path("pydantic.json"),
86
+ _exit: Annotated[bool, Argument(hidden=True)] = True,
87
+ ):
88
+ config = _load_or_pass(cfg)
89
+ client = SystemdClient(config)
90
+ client.stop_timers()
91
+ client.stop_services()
92
+ config.rmdir()
93
+ client.daemon_reload()
94
+ return _raise_or_exit(True, _exit)
95
+
96
+
97
+ def _add_to_typer(app: Typer, command: SystemdTaskStep, function) -> None:
98
+ app.command(command)(function)
99
+
100
+
101
+ def main() -> None:
102
+ app = Typer()
103
+ _add_to_typer(app, "configure-systemd", write_systemd_config)
104
+ _add_to_typer(app, "start-services", start_services)
105
+ _add_to_typer(app, "check-services", check_services)
106
+ _add_to_typer(app, "restart-services", restart_services)
107
+ _add_to_typer(app, "stop-services", stop_services)
108
+ _add_to_typer(app, "unconfigure-systemd", remove_systemd_config)
109
+ app()
@@ -0,0 +1,12 @@
1
+ from typing import Literal
2
+
3
+ SystemdTaskStep = Literal[
4
+ "configure-systemd",
5
+ "start-services",
6
+ "check-services",
7
+ "restart-services",
8
+ "stop-services",
9
+ "unconfigure-systemd",
10
+ ]
11
+
12
+ __all__ = ("SystemdTaskStep",)