nullforge 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.
- nullforge/__init__.py +1 -0
- nullforge/foundry/README.md +0 -0
- nullforge/foundry/__init__.py +7 -0
- nullforge/foundry/full_cast.py +22 -0
- nullforge/inventories/README.md +0 -0
- nullforge/inventories/example.py +87 -0
- nullforge/models/__init__.py +1 -0
- nullforge/models/containers.py +60 -0
- nullforge/models/dns.py +222 -0
- nullforge/models/monitoring/__init__.py +29 -0
- nullforge/models/monitoring/base.py +19 -0
- nullforge/models/monitoring/nezha.py +13 -0
- nullforge/models/netsec.py +43 -0
- nullforge/models/profiles.py +33 -0
- nullforge/models/system.py +15 -0
- nullforge/models/users.py +9 -0
- nullforge/models/warp.py +65 -0
- nullforge/models/zerotrust.py +9 -0
- nullforge/molds/__init__.py +38 -0
- nullforge/molds/base_mold.py +59 -0
- nullforge/molds/containers.py +26 -0
- nullforge/molds/defaults.py +10 -0
- nullforge/molds/dns.py +70 -0
- nullforge/molds/features.py +75 -0
- nullforge/molds/haproxy.py +23 -0
- nullforge/molds/monitoring/__init__.py +45 -0
- nullforge/molds/monitoring/nezha.py +118 -0
- nullforge/molds/netsec.py +237 -0
- nullforge/molds/profiles.py +30 -0
- nullforge/molds/system.py +140 -0
- nullforge/molds/telemt.py +140 -0
- nullforge/molds/tor.py +28 -0
- nullforge/molds/user.py +77 -0
- nullforge/molds/utils.py +115 -0
- nullforge/molds/warp.py +54 -0
- nullforge/molds/xray.py +16 -0
- nullforge/molds/zerotrust.py +59 -0
- nullforge/runes/__init__.py +46 -0
- nullforge/runes/base.py +327 -0
- nullforge/runes/containers.py +212 -0
- nullforge/runes/dns.py +256 -0
- nullforge/runes/haproxy.py +135 -0
- nullforge/runes/monitoring.py +21 -0
- nullforge/runes/netsec.py +547 -0
- nullforge/runes/prepare.py +30 -0
- nullforge/runes/profiles.py +583 -0
- nullforge/runes/telemt.py +252 -0
- nullforge/runes/tor.py +57 -0
- nullforge/runes/users.py +159 -0
- nullforge/runes/warp.py +273 -0
- nullforge/runes/xray.py +81 -0
- nullforge/runes/zerotrust.py +91 -0
- nullforge/smithy/__init__.py +1 -0
- nullforge/smithy/admin.py +40 -0
- nullforge/smithy/arch.py +36 -0
- nullforge/smithy/blocky.py +37 -0
- nullforge/smithy/cloudflare.py +32 -0
- nullforge/smithy/github.py +256 -0
- nullforge/smithy/http.py +85 -0
- nullforge/smithy/install.py +149 -0
- nullforge/smithy/monitoring/__init__.py +1 -0
- nullforge/smithy/monitoring/nezha/__init__.py +0 -0
- nullforge/smithy/monitoring/nezha/agent.py +54 -0
- nullforge/smithy/monitoring/nezha/dashboard.py +137 -0
- nullforge/smithy/monitoring/nezha/deploy.py +81 -0
- nullforge/smithy/network.py +95 -0
- nullforge/smithy/packages.py +176 -0
- nullforge/smithy/service.py +37 -0
- nullforge/smithy/sni.py +358 -0
- nullforge/smithy/swap.py +132 -0
- nullforge/smithy/system.py +86 -0
- nullforge/smithy/versions.py +282 -0
- nullforge/templates/__init__.py +32 -0
- nullforge/templates/cloudflared/tunnel.yml.j2 +6 -0
- nullforge/templates/dns/blocky.yaml.j2 +11 -0
- nullforge/templates/dns/dns.yaml.j2 +6 -0
- nullforge/templates/dns/resolv.conf.j2 +1 -0
- nullforge/templates/dns/resolved.conf.j2 +13 -0
- nullforge/templates/etc/default/zramswap.j2 +13 -0
- nullforge/templates/nvim/nvim_patch.lua.j2 +5 -0
- nullforge/templates/profiles/direnv.toml +2 -0
- nullforge/templates/profiles/starship.toml +186 -0
- nullforge/templates/profiles/tmux.conf +32 -0
- nullforge/templates/profiles/zshrc.j2 +46 -0
- nullforge/templates/scripts/telemt-synfix.sh +89 -0
- nullforge/templates/scripts/teleproxy-warp.sh +127 -0
- nullforge/templates/scripts/warp-v6-policy.sh +118 -0
- nullforge/templates/scripts/zt-tunnel-warp.sh +28 -0
- nullforge/templates/systemd/blocky.service.j2 +19 -0
- nullforge/templates/systemd/cloudflare-tunnel.service.j2 +27 -0
- nullforge/templates/systemd/cloudflare-warp.service.j2 +21 -0
- nullforge/templates/systemd/dns-internal.service.j2 +15 -0
- nullforge/templates/systemd/telemt.service.j2 +36 -0
- nullforge/templates/telemt/telemt.toml.j2 +49 -0
- nullforge/templates/tor/torrc.j2 +5 -0
- nullforge-0.1.0.dist-info/METADATA +39 -0
- nullforge-0.1.0.dist-info/RECORD +99 -0
- nullforge-0.1.0.dist-info/WHEEL +4 -0
- nullforge-0.1.0.dist-info/licenses/LICENSE +7 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Nezha agent deploy orchestration."""
|
|
2
|
+
|
|
3
|
+
from pyinfra.context import host
|
|
4
|
+
from pyinfra.facts.files import File
|
|
5
|
+
from pyinfra.facts.server import Hostname
|
|
6
|
+
from pyinfra.operations import python, server, systemd
|
|
7
|
+
|
|
8
|
+
from nullforge.molds.monitoring import NezhaBackend
|
|
9
|
+
from nullforge.molds.system import SystemMold
|
|
10
|
+
from nullforge.smithy.http import CURL_ARGS_STR
|
|
11
|
+
from nullforge.smithy.versions import STATIC_URLS
|
|
12
|
+
|
|
13
|
+
from .agent import agent_uuid_for_hostname, build_nezha_install_command
|
|
14
|
+
from .dashboard import rename_nezha_server, server_name_matches
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_hostname(cfg: NezhaBackend) -> str:
|
|
18
|
+
system: SystemMold = host.data.system
|
|
19
|
+
if isinstance(system.hostname, str) and system.hostname:
|
|
20
|
+
return system.hostname
|
|
21
|
+
|
|
22
|
+
fact = host.get_fact(Hostname)
|
|
23
|
+
if isinstance(fact, str) and fact:
|
|
24
|
+
return fact
|
|
25
|
+
return cfg.layout.systemd_service_name
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def deploy_nezha(cfg: NezhaBackend) -> None:
|
|
29
|
+
"""Deploy Nezha agent and align dashboard name with hostname."""
|
|
30
|
+
|
|
31
|
+
layout = cfg.layout
|
|
32
|
+
hostname = _resolve_hostname(cfg)
|
|
33
|
+
agent_uuid = agent_uuid_for_hostname(hostname, namespace=cfg.uuid_namespace)
|
|
34
|
+
|
|
35
|
+
if not host.get_fact(File, layout.config_path):
|
|
36
|
+
install_command = build_nezha_install_command(
|
|
37
|
+
install_url=STATIC_URLS["nezha_agent_install"],
|
|
38
|
+
curl_args=CURL_ARGS_STR,
|
|
39
|
+
server=cfg.server,
|
|
40
|
+
tls=cfg.tls,
|
|
41
|
+
client_secret=cfg.client_secret,
|
|
42
|
+
agent_uuid=agent_uuid,
|
|
43
|
+
disable_auto_update=cfg.disable_auto_update,
|
|
44
|
+
disable_command_execute=cfg.disable_command_execute,
|
|
45
|
+
)
|
|
46
|
+
server.shell(
|
|
47
|
+
name="Install Nezha agent",
|
|
48
|
+
commands=[install_command],
|
|
49
|
+
_sudo=True,
|
|
50
|
+
_retries=3,
|
|
51
|
+
_retry_delay=10,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
systemd.service(
|
|
55
|
+
name="Ensure Nezha agent is running",
|
|
56
|
+
service=layout.systemd_service_name,
|
|
57
|
+
running=True,
|
|
58
|
+
enabled=True,
|
|
59
|
+
_sudo=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not cfg.set_name_to_hostname:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if server_name_matches(
|
|
66
|
+
dashboard_url=cfg.dashboard_url,
|
|
67
|
+
api_token=cfg.api_token,
|
|
68
|
+
agent_uuid=agent_uuid,
|
|
69
|
+
desired_name=hostname,
|
|
70
|
+
):
|
|
71
|
+
host.noop(f"Nezha dashboard already names {agent_uuid} '{hostname}'")
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
python.call(
|
|
75
|
+
name="Set Nezha dashboard name to hostname",
|
|
76
|
+
function=rename_nezha_server,
|
|
77
|
+
dashboard_url=cfg.dashboard_url,
|
|
78
|
+
api_token=cfg.api_token,
|
|
79
|
+
agent_uuid=agent_uuid,
|
|
80
|
+
desired_name=hostname,
|
|
81
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Network utilities for NullForge."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from pyinfra.context import host
|
|
6
|
+
from pyinfra.facts.files import FileContents
|
|
7
|
+
from pyinfra.facts.server import Command, KernelModules
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def conntrack_max_for(ram_mb: int) -> int:
|
|
11
|
+
"""Cap conntrack to ~2% of RAM as a backpressure ceiling (~300 B/entry)."""
|
|
12
|
+
|
|
13
|
+
entries = int(ram_mb * 1024 * 1024 * 0.02 / 300)
|
|
14
|
+
return max(16384, min(entries, 512 * 1024))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def module_loaded(module: str) -> bool:
|
|
18
|
+
"""Return True if named kernel module is currently loaded."""
|
|
19
|
+
|
|
20
|
+
return module in _loaded_kernel_modules()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _loaded_kernel_modules() -> dict[str, dict]:
|
|
24
|
+
cache_key = "_nullforge_kernel_modules"
|
|
25
|
+
if hasattr(host.data, cache_key):
|
|
26
|
+
return getattr(host.data, cache_key)
|
|
27
|
+
|
|
28
|
+
modules: dict[str, dict] = host.get_fact(KernelModules) or {}
|
|
29
|
+
|
|
30
|
+
setattr(host.data, cache_key, modules)
|
|
31
|
+
return modules
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_TUNNEL_IFACE_RE = re.compile(
|
|
35
|
+
r"^(?:warp|tun\d*|tap\d*|wg\d*|zt[0-9a-f]*|tailscale|ts\d|"
|
|
36
|
+
r"docker\d*|br-[0-9a-f]+|veth[0-9a-f]+|cni\d*|cali[0-9a-f]+|"
|
|
37
|
+
r"flannel|weave|ipip|sit|gre|ip6tnl|ip6gre)"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ipv6_grub_disabled() -> bool:
|
|
42
|
+
"""Return True if GRUB is configured with ipv6.disable=1."""
|
|
43
|
+
|
|
44
|
+
cache_key = "_nullforge_ipv6_grub_disabled"
|
|
45
|
+
if hasattr(host.data, cache_key):
|
|
46
|
+
return getattr(host.data, cache_key)
|
|
47
|
+
|
|
48
|
+
grub_contents = host.get_fact(FileContents, path="/etc/default/grub") or []
|
|
49
|
+
disabled = any("ipv6.disable=1" in line for line in grub_contents)
|
|
50
|
+
|
|
51
|
+
setattr(host.data, cache_key, disabled)
|
|
52
|
+
return disabled
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _ipv6_global_addrs_raw() -> str:
|
|
56
|
+
cache_key = "_nullforge_ipv6_global_raw"
|
|
57
|
+
if hasattr(host.data, cache_key):
|
|
58
|
+
return getattr(host.data, cache_key)
|
|
59
|
+
|
|
60
|
+
raw = host.get_fact(Command, "ip -6 -o addr show scope global 2>/dev/null || true") or ""
|
|
61
|
+
setattr(host.data, cache_key, raw)
|
|
62
|
+
return raw
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def has_ipv6(*, exclude_iface: str | None = None) -> bool:
|
|
66
|
+
"""Return whether the host has usable global IPv6."""
|
|
67
|
+
|
|
68
|
+
if exclude_iface is None:
|
|
69
|
+
cache_key = "_nullforge_ipv6_enabled"
|
|
70
|
+
if hasattr(host.data, cache_key):
|
|
71
|
+
return getattr(host.data, cache_key)
|
|
72
|
+
|
|
73
|
+
if _ipv6_grub_disabled():
|
|
74
|
+
setattr(host.data, "_nullforge_ipv6_enabled", False)
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
raw = _ipv6_global_addrs_raw()
|
|
78
|
+
|
|
79
|
+
result = False
|
|
80
|
+
for line in raw.splitlines():
|
|
81
|
+
parts = line.split(maxsplit=3)
|
|
82
|
+
if len(parts) < 3:
|
|
83
|
+
continue
|
|
84
|
+
iface = parts[1].rstrip(":")
|
|
85
|
+
if exclude_iface and iface == exclude_iface:
|
|
86
|
+
continue
|
|
87
|
+
if _TUNNEL_IFACE_RE.match(iface):
|
|
88
|
+
continue
|
|
89
|
+
if parts[2] == "inet6":
|
|
90
|
+
result = True
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
if exclude_iface is None:
|
|
94
|
+
setattr(host.data, "_nullforge_ipv6_enabled", result)
|
|
95
|
+
return result
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Package management utilities for NullForge."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
4
|
+
|
|
5
|
+
from pyinfra.context import host
|
|
6
|
+
from pyinfra.facts.server import LinuxDistribution
|
|
7
|
+
from pyinfra.operations import apt, dnf
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pyinfra.api.host import Host
|
|
12
|
+
|
|
13
|
+
UBUNTU_OVERRIDES = {
|
|
14
|
+
"ifupdown2": "ifupdown",
|
|
15
|
+
}
|
|
16
|
+
"""Specific overrides for Ubuntu"""
|
|
17
|
+
|
|
18
|
+
RHEL_OVERRIDES = {
|
|
19
|
+
"aha": None,
|
|
20
|
+
"apt-transport-https": None,
|
|
21
|
+
"bat": "bat",
|
|
22
|
+
"bind9-host": "bind-utils",
|
|
23
|
+
"build-essential": "@Development Tools",
|
|
24
|
+
"dnsutils": "bind-utils",
|
|
25
|
+
"g++": "gcc-c++",
|
|
26
|
+
"gnupg": "gnupg2",
|
|
27
|
+
"ifupdown2": None,
|
|
28
|
+
"iputils-ping": "iputils",
|
|
29
|
+
"libevent-dev": "libevent-devel",
|
|
30
|
+
"libnss-resolve": "systemd-resolved",
|
|
31
|
+
"libssl-dev": "openssl-devel",
|
|
32
|
+
"locales": "glibc-langpack-en",
|
|
33
|
+
"mtr-tiny": "mtr",
|
|
34
|
+
"ncat": "nmap-ncat",
|
|
35
|
+
"ncurses-dev": "ncurses-devel",
|
|
36
|
+
"pkg-config": "pkgconfig",
|
|
37
|
+
"python3-dev": "python3-devel",
|
|
38
|
+
"software-properties-common": None,
|
|
39
|
+
"ufw": "firewalld",
|
|
40
|
+
}
|
|
41
|
+
"""Package overrides for RHEL/CentOS/Fedora families
|
|
42
|
+
Key: Canonical name (Debian/Ubuntu style)
|
|
43
|
+
Value: RHEL equivalent (or None to skip)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SystemPackageModule(Protocol):
|
|
48
|
+
"""Protocol for pyinfra package manager modules (apt, dnf, etc)."""
|
|
49
|
+
|
|
50
|
+
def packages(self, packages: list[str] | None = None, **kwargs: Any) -> Any: ...
|
|
51
|
+
def update(self, **kwargs: Any) -> Any: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AptModule(SystemPackageModule, Protocol):
|
|
55
|
+
"""Protocol for apt module which includes upgrade."""
|
|
56
|
+
|
|
57
|
+
def upgrade(self, **kwargs: Any) -> Any: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PackageManager:
|
|
61
|
+
def __init__(self, host: "Host"):
|
|
62
|
+
self.host = host
|
|
63
|
+
self.distro_info = host.get_fact(LinuxDistribution)
|
|
64
|
+
self.distro_name = (self.distro_info.get("name") or "").lower()
|
|
65
|
+
self.distro_major = self.distro_info.get("major", 0) or 0
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_debian_based(self) -> bool:
|
|
69
|
+
return any(x in self.distro_name for x in ["debian", "ubuntu"])
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_rhel_based(self) -> bool:
|
|
73
|
+
return any(x in self.distro_name for x in ["centos", "redhat", "rhel", "fedora", "rocky", "alma"])
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def is_ubuntu(self) -> bool:
|
|
77
|
+
return "ubuntu" in self.distro_name
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_debian(self) -> bool:
|
|
81
|
+
return "debian" in self.distro_name
|
|
82
|
+
|
|
83
|
+
def _get_module(self) -> SystemPackageModule:
|
|
84
|
+
if self.is_debian_based:
|
|
85
|
+
return cast(SystemPackageModule, apt)
|
|
86
|
+
elif self.is_rhel_based:
|
|
87
|
+
return cast(SystemPackageModule, dnf)
|
|
88
|
+
else:
|
|
89
|
+
raise NotImplementedError(f"Unsupported distribution: {self.distro_name}")
|
|
90
|
+
|
|
91
|
+
def map_package(self, package: str) -> str | None:
|
|
92
|
+
"""Map a package name to the current distro's equivalent."""
|
|
93
|
+
|
|
94
|
+
if self.is_ubuntu and package in UBUNTU_OVERRIDES:
|
|
95
|
+
return UBUNTU_OVERRIDES[package]
|
|
96
|
+
|
|
97
|
+
if self.is_debian_based:
|
|
98
|
+
return package
|
|
99
|
+
|
|
100
|
+
if self.is_rhel_based and package in RHEL_OVERRIDES:
|
|
101
|
+
return RHEL_OVERRIDES[package]
|
|
102
|
+
|
|
103
|
+
# TODO: Maybe make better fallback mechanism
|
|
104
|
+
return package
|
|
105
|
+
|
|
106
|
+
def map_packages(self, packages: list[str]) -> list[str]:
|
|
107
|
+
"""Map a list of packages."""
|
|
108
|
+
|
|
109
|
+
mapped = set()
|
|
110
|
+
for p in packages:
|
|
111
|
+
m = self.map_package(p)
|
|
112
|
+
if m:
|
|
113
|
+
mapped.add(m)
|
|
114
|
+
return list(mapped)
|
|
115
|
+
|
|
116
|
+
def update(self, **kwargs: Any) -> Any:
|
|
117
|
+
"""Refresh the package repository cache.
|
|
118
|
+
|
|
119
|
+
On Debian/Ubuntu: runs `apt update`.
|
|
120
|
+
On RHEL/Fedora: no-op — dnf has no separate cache refresh step;
|
|
121
|
+
`dnf upgrade` fetches fresh metadata automatically.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
mod = self._get_module()
|
|
125
|
+
if self.is_debian_based:
|
|
126
|
+
return mod.update(**kwargs)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def upgrade(self, **kwargs: Any) -> Any:
|
|
130
|
+
"""Upgrade all installed packages.
|
|
131
|
+
|
|
132
|
+
On Debian/Ubuntu: runs `apt upgrade`.
|
|
133
|
+
On RHEL/Fedora: runs `dnf upgrade`, which also refreshes metadata.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
call_kwargs = kwargs.copy()
|
|
137
|
+
mod = self._get_module()
|
|
138
|
+
|
|
139
|
+
if not self.is_debian_based:
|
|
140
|
+
call_kwargs.pop("auto_remove", None)
|
|
141
|
+
|
|
142
|
+
if self.is_debian_based:
|
|
143
|
+
return cast(AptModule, mod).upgrade(**call_kwargs)
|
|
144
|
+
elif self.is_rhel_based:
|
|
145
|
+
return mod.update(**call_kwargs)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def install(self, packages: list[str], **kwargs: Any) -> Any:
|
|
149
|
+
"""Install packages."""
|
|
150
|
+
|
|
151
|
+
mod = self._get_module()
|
|
152
|
+
mapped_packages = self.map_packages(packages)
|
|
153
|
+
if not mapped_packages:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
call_kwargs = kwargs.copy()
|
|
157
|
+
|
|
158
|
+
# Remove apt-specific kwargs if not apt
|
|
159
|
+
if not self.is_debian_based:
|
|
160
|
+
call_kwargs.pop("no_recommends", None)
|
|
161
|
+
call_kwargs.pop("force", None)
|
|
162
|
+
call_kwargs.pop("cache_time", None)
|
|
163
|
+
call_kwargs.pop("extra_uninstall_args", None)
|
|
164
|
+
|
|
165
|
+
return mod.packages(packages=mapped_packages, **call_kwargs)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_pm() -> PackageManager:
|
|
169
|
+
"""Get or create the PackageManager for the current host."""
|
|
170
|
+
|
|
171
|
+
if hasattr(host.data, "_nullforge_package_manager"):
|
|
172
|
+
return getattr(host.data, "_nullforge_package_manager")
|
|
173
|
+
|
|
174
|
+
pm = PackageManager(host)
|
|
175
|
+
setattr(host.data, "_nullforge_package_manager", pm)
|
|
176
|
+
return pm
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared helpers for provisioning system service users and config directories."""
|
|
2
|
+
|
|
3
|
+
from pyinfra.context import host
|
|
4
|
+
from pyinfra.facts.server import Groups, Users
|
|
5
|
+
from pyinfra.operations import files, server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ensure_service_user(user: str, group: str, config_dir: str) -> None:
|
|
9
|
+
"""Ensure a system service user, group, and config directory exist."""
|
|
10
|
+
|
|
11
|
+
if group not in host.get_fact(Groups):
|
|
12
|
+
server.group(
|
|
13
|
+
name=f"Ensure {group} group exists",
|
|
14
|
+
group=group,
|
|
15
|
+
system=True,
|
|
16
|
+
_sudo=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if user not in host.get_fact(Users):
|
|
20
|
+
server.user( # noqa: S604
|
|
21
|
+
name=f"Ensure {user} system user exists",
|
|
22
|
+
user=user,
|
|
23
|
+
system=True,
|
|
24
|
+
group=group,
|
|
25
|
+
shell="/bin/false",
|
|
26
|
+
create_home=False,
|
|
27
|
+
_sudo=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
files.directory(
|
|
31
|
+
name=f"Ensure {config_dir} configuration directory exists",
|
|
32
|
+
path=config_dir,
|
|
33
|
+
user=user,
|
|
34
|
+
group=group,
|
|
35
|
+
mode="0755",
|
|
36
|
+
_sudo=True,
|
|
37
|
+
)
|