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.
Files changed (99) hide show
  1. nullforge/__init__.py +1 -0
  2. nullforge/foundry/README.md +0 -0
  3. nullforge/foundry/__init__.py +7 -0
  4. nullforge/foundry/full_cast.py +22 -0
  5. nullforge/inventories/README.md +0 -0
  6. nullforge/inventories/example.py +87 -0
  7. nullforge/models/__init__.py +1 -0
  8. nullforge/models/containers.py +60 -0
  9. nullforge/models/dns.py +222 -0
  10. nullforge/models/monitoring/__init__.py +29 -0
  11. nullforge/models/monitoring/base.py +19 -0
  12. nullforge/models/monitoring/nezha.py +13 -0
  13. nullforge/models/netsec.py +43 -0
  14. nullforge/models/profiles.py +33 -0
  15. nullforge/models/system.py +15 -0
  16. nullforge/models/users.py +9 -0
  17. nullforge/models/warp.py +65 -0
  18. nullforge/models/zerotrust.py +9 -0
  19. nullforge/molds/__init__.py +38 -0
  20. nullforge/molds/base_mold.py +59 -0
  21. nullforge/molds/containers.py +26 -0
  22. nullforge/molds/defaults.py +10 -0
  23. nullforge/molds/dns.py +70 -0
  24. nullforge/molds/features.py +75 -0
  25. nullforge/molds/haproxy.py +23 -0
  26. nullforge/molds/monitoring/__init__.py +45 -0
  27. nullforge/molds/monitoring/nezha.py +118 -0
  28. nullforge/molds/netsec.py +237 -0
  29. nullforge/molds/profiles.py +30 -0
  30. nullforge/molds/system.py +140 -0
  31. nullforge/molds/telemt.py +140 -0
  32. nullforge/molds/tor.py +28 -0
  33. nullforge/molds/user.py +77 -0
  34. nullforge/molds/utils.py +115 -0
  35. nullforge/molds/warp.py +54 -0
  36. nullforge/molds/xray.py +16 -0
  37. nullforge/molds/zerotrust.py +59 -0
  38. nullforge/runes/__init__.py +46 -0
  39. nullforge/runes/base.py +327 -0
  40. nullforge/runes/containers.py +212 -0
  41. nullforge/runes/dns.py +256 -0
  42. nullforge/runes/haproxy.py +135 -0
  43. nullforge/runes/monitoring.py +21 -0
  44. nullforge/runes/netsec.py +547 -0
  45. nullforge/runes/prepare.py +30 -0
  46. nullforge/runes/profiles.py +583 -0
  47. nullforge/runes/telemt.py +252 -0
  48. nullforge/runes/tor.py +57 -0
  49. nullforge/runes/users.py +159 -0
  50. nullforge/runes/warp.py +273 -0
  51. nullforge/runes/xray.py +81 -0
  52. nullforge/runes/zerotrust.py +91 -0
  53. nullforge/smithy/__init__.py +1 -0
  54. nullforge/smithy/admin.py +40 -0
  55. nullforge/smithy/arch.py +36 -0
  56. nullforge/smithy/blocky.py +37 -0
  57. nullforge/smithy/cloudflare.py +32 -0
  58. nullforge/smithy/github.py +256 -0
  59. nullforge/smithy/http.py +85 -0
  60. nullforge/smithy/install.py +149 -0
  61. nullforge/smithy/monitoring/__init__.py +1 -0
  62. nullforge/smithy/monitoring/nezha/__init__.py +0 -0
  63. nullforge/smithy/monitoring/nezha/agent.py +54 -0
  64. nullforge/smithy/monitoring/nezha/dashboard.py +137 -0
  65. nullforge/smithy/monitoring/nezha/deploy.py +81 -0
  66. nullforge/smithy/network.py +95 -0
  67. nullforge/smithy/packages.py +176 -0
  68. nullforge/smithy/service.py +37 -0
  69. nullforge/smithy/sni.py +358 -0
  70. nullforge/smithy/swap.py +132 -0
  71. nullforge/smithy/system.py +86 -0
  72. nullforge/smithy/versions.py +282 -0
  73. nullforge/templates/__init__.py +32 -0
  74. nullforge/templates/cloudflared/tunnel.yml.j2 +6 -0
  75. nullforge/templates/dns/blocky.yaml.j2 +11 -0
  76. nullforge/templates/dns/dns.yaml.j2 +6 -0
  77. nullforge/templates/dns/resolv.conf.j2 +1 -0
  78. nullforge/templates/dns/resolved.conf.j2 +13 -0
  79. nullforge/templates/etc/default/zramswap.j2 +13 -0
  80. nullforge/templates/nvim/nvim_patch.lua.j2 +5 -0
  81. nullforge/templates/profiles/direnv.toml +2 -0
  82. nullforge/templates/profiles/starship.toml +186 -0
  83. nullforge/templates/profiles/tmux.conf +32 -0
  84. nullforge/templates/profiles/zshrc.j2 +46 -0
  85. nullforge/templates/scripts/telemt-synfix.sh +89 -0
  86. nullforge/templates/scripts/teleproxy-warp.sh +127 -0
  87. nullforge/templates/scripts/warp-v6-policy.sh +118 -0
  88. nullforge/templates/scripts/zt-tunnel-warp.sh +28 -0
  89. nullforge/templates/systemd/blocky.service.j2 +19 -0
  90. nullforge/templates/systemd/cloudflare-tunnel.service.j2 +27 -0
  91. nullforge/templates/systemd/cloudflare-warp.service.j2 +21 -0
  92. nullforge/templates/systemd/dns-internal.service.j2 +15 -0
  93. nullforge/templates/systemd/telemt.service.j2 +36 -0
  94. nullforge/templates/telemt/telemt.toml.j2 +49 -0
  95. nullforge/templates/tor/torrc.j2 +5 -0
  96. nullforge-0.1.0.dist-info/METADATA +39 -0
  97. nullforge-0.1.0.dist-info/RECORD +99 -0
  98. nullforge-0.1.0.dist-info/WHEEL +4 -0
  99. nullforge-0.1.0.dist-info/licenses/LICENSE +7 -0
@@ -0,0 +1,115 @@
1
+ """Utility functions for merging and ensuring models."""
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from .base_mold import BaseMold
7
+ from .features import ALLOWED_FEATURES_LAYERS, FEATURE_MOLD_MAPPING, FeaturesMold
8
+ from .system import SystemMold
9
+
10
+
11
+ def _deep_merge_dicts(a: Mapping[str, Any], b: Mapping[str, Any]) -> dict[str, Any]:
12
+ """
13
+ Recursively merge dict b into dict a without mutating a.
14
+ None/empty containers in b will overwrite a.
15
+ """
16
+
17
+ out = dict(a)
18
+ for k, v in b.items():
19
+ if k in out and isinstance(out[k], dict) and isinstance(v, dict):
20
+ out[k] = _deep_merge_dicts(out[k], v)
21
+ else:
22
+ out[k] = v
23
+ return out
24
+
25
+
26
+ def _to_features_fragment(value: object) -> dict[str, Any]:
27
+ """
28
+ Accept a full FeaturesMold, any sub-mold (UserMold, WarpMold, ...), a mapping,
29
+ or None; return a dict fragment suitable for deep-merge into FeaturesMold.
30
+ """
31
+
32
+ match value:
33
+ case None:
34
+ return {}
35
+ case FeaturesMold():
36
+ return value.model_dump()
37
+ case BaseMold():
38
+ for attr, cls in FEATURE_MOLD_MAPPING.items():
39
+ if isinstance(value, cls):
40
+ return {attr: value.model_dump()}
41
+ raise TypeError(f"Unsupported features layer type: {type(value)!r}")
42
+ case Mapping():
43
+ prepared = {k: (v.model_dump() if isinstance(v, ALLOWED_FEATURES_LAYERS) else v) for k, v in value.items()}
44
+ return FeaturesMold.model_validate(prepared).model_dump(exclude_unset=True)
45
+ case _:
46
+ raise TypeError(f"Unsupported features layer type: {type(value)!r}")
47
+
48
+
49
+ def merge_features(base: FeaturesMold, *layers: object | None) -> FeaturesMold:
50
+ """Start from base FeaturesMold, deep-merge each layer (full mold, sub-mold, dict, or None)."""
51
+
52
+ acc = base.model_dump()
53
+ for layer in layers:
54
+ if layer is None:
55
+ continue
56
+ acc = _deep_merge_dicts(acc, _to_features_fragment(layer))
57
+ return FeaturesMold.model_validate(acc)
58
+
59
+
60
+ def ensure_features(value: object) -> FeaturesMold:
61
+ """
62
+ Coerce whatever is in inventory (None/dict/Features) into a Features instance,
63
+ falling back to model defaults when absent.
64
+ """
65
+
66
+ match value:
67
+ case None:
68
+ return FeaturesMold()
69
+ case FeaturesMold():
70
+ return value
71
+ case Mapping():
72
+ return FeaturesMold.model_validate(value)
73
+ case _:
74
+ raise TypeError(f"Unsupported features layer type: {type(value)!r}")
75
+
76
+
77
+ def _to_system_dict(value: SystemMold | Mapping[str, Any]) -> dict[str, Any]:
78
+ match value:
79
+ case SystemMold():
80
+ return value.model_dump()
81
+ case Mapping():
82
+ return SystemMold.model_validate(value).model_dump(exclude_unset=True)
83
+ case _:
84
+ raise TypeError(f"Unsupported system layer type: {type(value)!r}")
85
+
86
+
87
+ def merge_system(base: SystemMold, *layers: SystemMold | Mapping[str, Any] | None) -> SystemMold:
88
+ """
89
+ - start from base SystemMold
90
+ - apply each layer (dict or SystemMold), deep-merging into the accumulated dict
91
+ """
92
+
93
+ acc = base.model_dump()
94
+ for layer in layers:
95
+ if layer is None:
96
+ continue
97
+ acc = _deep_merge_dicts(acc, _to_system_dict(layer))
98
+ return SystemMold.model_validate(acc)
99
+
100
+
101
+ def ensure_system(value: object) -> SystemMold:
102
+ """
103
+ Coerce whatever is in inventory (None/dict/SystemMold) into a SystemMold instance,
104
+ falling back to model defaults when absent.
105
+ """
106
+
107
+ match value:
108
+ case None:
109
+ return SystemMold()
110
+ case SystemMold():
111
+ return value
112
+ case Mapping():
113
+ return SystemMold.model_validate(value)
114
+ case _:
115
+ raise TypeError(f"Unsupported system layer type: {type(value)!r}")
@@ -0,0 +1,54 @@
1
+ """WARP configuration mold."""
2
+
3
+ from functools import cached_property
4
+ from typing import TYPE_CHECKING, Literal
5
+
6
+ from pydantic import Field, field_validator
7
+
8
+ from nullforge.models.warp import WarpEngineType, warp_engine_factory
9
+
10
+ from .base_mold import BaseMold
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from nullforge.models.warp import WarpEngine
15
+
16
+
17
+ class WarpMold(BaseMold):
18
+ install: bool = Field(
19
+ default=False,
20
+ description="Whether to deploy WARP",
21
+ )
22
+ engine_type: WarpEngineType = Field(
23
+ default=WarpEngineType.MASQUE,
24
+ description="The WARP engine to use",
25
+ )
26
+ iface: str = Field(
27
+ default="warp",
28
+ description="The name of the network interface for WARP",
29
+ )
30
+ mtu: int = Field(
31
+ default=1280,
32
+ ge=1280,
33
+ le=9216,
34
+ description="The MTU for the WARP interface",
35
+ )
36
+ zero_trust: Literal[False] = Field(
37
+ default=False,
38
+ description="ZeroTrust enrollment — not yet implemented, always False",
39
+ )
40
+
41
+ @property
42
+ def is_active(self) -> bool:
43
+ return self.install
44
+
45
+ @field_validator("iface")
46
+ @classmethod
47
+ def _validate_iface(cls, v: str) -> str:
48
+ if not v or any(ch.isspace() for ch in v):
49
+ raise ValueError("iface must be a non-empty string without spaces")
50
+ return v
51
+
52
+ @cached_property
53
+ def engine(self) -> "WarpEngine":
54
+ return warp_engine_factory(self.engine_type)
@@ -0,0 +1,16 @@
1
+ """Xray-core configuration mold."""
2
+
3
+ from pydantic import Field
4
+
5
+ from .base_mold import BaseMold
6
+
7
+
8
+ class XrayCoreMold(BaseMold):
9
+ install: bool = Field(
10
+ default=False,
11
+ description="Whether to install Xray core",
12
+ )
13
+
14
+ @property
15
+ def is_active(self) -> bool:
16
+ return self.install
@@ -0,0 +1,59 @@
1
+ """Zero Trust Tunnel configuration mold."""
2
+
3
+ from pydantic import Field, field_validator, model_validator
4
+
5
+ from nullforge.models.zerotrust import ZeroTrustTunnelProtocol
6
+
7
+ from .base_mold import BaseMold
8
+
9
+
10
+ class ZeroTrustTunnelMold(BaseMold):
11
+ install: bool = Field(
12
+ default=False,
13
+ description="Whether to deploy Zero Trust Tunnel",
14
+ )
15
+ token: str | None = Field(
16
+ default=None,
17
+ description="Tunnel token for authentication",
18
+ )
19
+ protocol: ZeroTrustTunnelProtocol = Field(
20
+ default=ZeroTrustTunnelProtocol.AUTO,
21
+ description="Tunnel protocol",
22
+ )
23
+ post_quantum: bool = Field(
24
+ default=False,
25
+ description="Whether to use post-quantum protocol",
26
+ )
27
+ ha_connections: int = Field(
28
+ default=2,
29
+ ge=1,
30
+ le=4,
31
+ description="Number of high-availability connections",
32
+ )
33
+ route_through_warp: bool = Field(
34
+ default=False,
35
+ description="Whether to route tunnel traffic through WARP interface",
36
+ )
37
+
38
+ @property
39
+ def is_active(self) -> bool:
40
+ return self.install
41
+
42
+ @field_validator("token")
43
+ @classmethod
44
+ def _strip_token(cls, v: str | None) -> str | None:
45
+ return v.strip() if v is not None else None
46
+
47
+ @model_validator(mode="after")
48
+ def validate_install_requires_token(self) -> "ZeroTrustTunnelMold":
49
+ if self.install and not self.token:
50
+ raise ValueError("token is required when install is True")
51
+ return self
52
+
53
+ @model_validator(mode="after")
54
+ def validate_post_quantum_requires_protocol(self) -> "ZeroTrustTunnelMold":
55
+ if self.post_quantum and self.route_through_warp:
56
+ raise ValueError("post-quantum protocol is not supported when routing through WARP")
57
+ if self.post_quantum and self.protocol == ZeroTrustTunnelProtocol.HTTP2:
58
+ raise ValueError("post-quantum key agreement requires QUIC protocol")
59
+ return self
@@ -0,0 +1,46 @@
1
+ """Rune metadata."""
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Final
7
+
8
+
9
+ RUNES_DIR: Final[Path] = Path(__file__).parent
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class RuneInfo:
14
+ name: str
15
+ path: Path
16
+ summary: str
17
+
18
+
19
+ def rune_path(name: str) -> Path:
20
+ """Absolute path of built-in rune."""
21
+
22
+ return RUNES_DIR / f"{name}.py"
23
+
24
+
25
+ def rune_summary(path: Path) -> str:
26
+ """First line of the rune docstring, extracted via ast."""
27
+
28
+ try:
29
+ module = ast.parse(path.read_text(encoding="utf-8"))
30
+ except (OSError, SyntaxError, ValueError):
31
+ return ""
32
+ docstring = ast.get_docstring(module)
33
+ if not docstring:
34
+ return ""
35
+ return docstring.strip().splitlines()[0]
36
+
37
+
38
+ def discover_runes(directory: Path | None = None) -> tuple[RuneInfo, ...]:
39
+ """Built-in runes: sorted *.py files in *directory*, skipping _-prefixed names."""
40
+
41
+ directory = directory if directory is not None else RUNES_DIR
42
+ try:
43
+ candidates = sorted(path for path in directory.glob("*.py") if not path.name.startswith("_"))
44
+ except OSError:
45
+ return ()
46
+ return tuple(RuneInfo(name=path.stem, path=path, summary=rune_summary(path)) for path in candidates)
@@ -0,0 +1,327 @@
1
+ """Base system configuration deployment module."""
2
+
3
+ import re
4
+
5
+ from pyinfra.context import host
6
+ from pyinfra.facts.files import File, FileContents
7
+ from pyinfra.facts.server import Command, Hostname, Locales
8
+ from pyinfra.operations import files, server, systemd
9
+
10
+ from nullforge.molds import SystemMold
11
+ from nullforge.smithy.install import install_release_binary
12
+ from nullforge.smithy.packages import get_pm
13
+ from nullforge.smithy.swap import configure_swap
14
+ from nullforge.smithy.system import detect_best_locale
15
+ from nullforge.smithy.versions import get_versions, is_pinned_version_installed
16
+
17
+
18
+ def deploy_base_system() -> None:
19
+ """Deploy base system configuration."""
20
+
21
+ system: SystemMold = host.data.system
22
+
23
+ if system.ensure_ipv6:
24
+ _ensure_ipv6_stack()
25
+
26
+ if system.hostname:
27
+ _configure_hostname(system.hostname)
28
+
29
+ _install_packages(system)
30
+
31
+ _set_locale(system)
32
+
33
+ _set_timezone(system)
34
+
35
+ _configure_ntp()
36
+
37
+ configure_swap(system)
38
+
39
+
40
+ def _configure_hostname(hostname: str) -> None:
41
+ """Configure system hostname."""
42
+
43
+ short_hostname = hostname.split(".")[0]
44
+
45
+ ip_fact = host.get_fact(Command, "hostname -I | awk '{print $1}'") or ""
46
+ ip = ip_fact.strip() or "127.0.1.1"
47
+
48
+ if host.get_fact(Hostname) != hostname:
49
+ server.hostname(
50
+ name="Set system hostname",
51
+ hostname=hostname,
52
+ _sudo=True,
53
+ )
54
+
55
+ server.etc_hosts(
56
+ name="Ensure hostname in hosts file",
57
+ ip=ip,
58
+ hostnames=[hostname, short_hostname],
59
+ _sudo=True,
60
+ )
61
+
62
+ _disable_cloud_init_hostname()
63
+
64
+
65
+ def _disable_cloud_init_hostname() -> None:
66
+ """Prevent cloud-init from overriding the configured hostname on reboot."""
67
+
68
+ cloud_cfg = "/etc/cloud/cloud.cfg"
69
+ if not host.get_fact(File, cloud_cfg):
70
+ return
71
+
72
+ files.line(
73
+ name="Preserve hostname across cloud-init runs",
74
+ path=cloud_cfg,
75
+ line=r"^preserve_hostname:.*",
76
+ replace="preserve_hostname: true",
77
+ _sudo=True,
78
+ )
79
+
80
+ for module in host.loop(("set_hostname", "update_hostname")):
81
+ files.line(
82
+ name=f"Remove {module} from cloud_init_modules",
83
+ path=cloud_cfg,
84
+ line=rf"^\s*-\s+{module}\s*$",
85
+ present=False,
86
+ _sudo=True,
87
+ )
88
+
89
+
90
+ def _install_packages(system: SystemMold) -> None:
91
+ """Install base system packages."""
92
+
93
+ pm = get_pm()
94
+
95
+ pm.update(
96
+ name="Update package repositories",
97
+ _sudo=True,
98
+ )
99
+
100
+ pm.upgrade(
101
+ name="Update packages",
102
+ _sudo=True,
103
+ )
104
+
105
+ pm.install(
106
+ name="Install base system packages",
107
+ packages=system.packages_base,
108
+ no_recommends=True,
109
+ _sudo=True,
110
+ )
111
+
112
+ _install_curl()
113
+
114
+ _install_doggo()
115
+
116
+
117
+ def _install_curl() -> None:
118
+ """Install curl package."""
119
+
120
+ pm = get_pm()
121
+ curl_exec_path = "/usr/local/bin/curl"
122
+ if is_pinned_version_installed("curl", curl_exec_path):
123
+ return
124
+
125
+ try:
126
+ curl_url = get_versions().curl_tar()
127
+ except ValueError:
128
+ pm.install(
129
+ name="Install curl package from repo",
130
+ packages=["curl"],
131
+ _sudo=True,
132
+ )
133
+ return
134
+
135
+ install_release_binary(
136
+ name="Install curl binary from static-curl",
137
+ url=curl_url,
138
+ dest=curl_exec_path,
139
+ binary_name="curl",
140
+ )
141
+
142
+ if not pm.is_debian_based:
143
+ files.link(
144
+ name="Symlink Debian-style CA bundle path for static curl",
145
+ path="/etc/ssl/certs/ca-certificates.crt",
146
+ target="/etc/pki/tls/certs/ca-bundle.crt",
147
+ _sudo=True,
148
+ )
149
+ return
150
+
151
+ pm.install(
152
+ name="Remove curl package",
153
+ packages=["curl"],
154
+ present=False,
155
+ _sudo=True,
156
+ )
157
+
158
+ pm.upgrade(
159
+ name="Clean up unused packages",
160
+ auto_remove=True,
161
+ _sudo=True,
162
+ )
163
+
164
+
165
+ def _install_doggo() -> None:
166
+ """Install doggo package."""
167
+
168
+ if is_pinned_version_installed("doggo", "/usr/local/bin/doggo"):
169
+ return
170
+
171
+ install_release_binary(
172
+ name="Download and extract doggo",
173
+ url=get_versions().doggo_tar(),
174
+ dest="/usr/local/bin/doggo",
175
+ binary_name="doggo",
176
+ )
177
+
178
+
179
+ def _set_locale(system: SystemMold) -> None:
180
+ """Set system locale."""
181
+
182
+ resolved_locales = set[str]()
183
+
184
+ for locale in system.locales:
185
+ best = detect_best_locale(preferred=locale)
186
+ if best:
187
+ resolved_locales.add(best)
188
+
189
+ if not resolved_locales:
190
+ return
191
+
192
+ installed = host.get_fact(Locales)
193
+ needs_gen = False
194
+
195
+ for gen_line in host.loop(sorted(resolved_locales)):
196
+ name = gen_line.split()[0]
197
+ files.line(
198
+ name=f"Enable locale {name}",
199
+ path="/etc/locale.gen",
200
+ line=rf"^#?\s*{re.escape(gen_line)}$",
201
+ replace=gen_line,
202
+ _sudo=True,
203
+ )
204
+ if name not in installed:
205
+ needs_gen = True
206
+
207
+ if needs_gen:
208
+ server.shell(
209
+ name="Generate locales",
210
+ commands=["locale-gen"],
211
+ _sudo=True,
212
+ )
213
+
214
+
215
+ def _configure_ntp() -> None:
216
+ """Install and configure NTP synchronization."""
217
+
218
+ pm = get_pm()
219
+
220
+ if pm.is_debian_based:
221
+ pm.install(
222
+ name="Install systemd-timesyncd",
223
+ packages=["systemd-timesyncd"],
224
+ _sudo=True,
225
+ )
226
+ systemd.service(
227
+ name="Ensure systemd-timesyncd is running and enabled",
228
+ service="systemd-timesyncd",
229
+ running=True,
230
+ enabled=True,
231
+ _sudo=True,
232
+ )
233
+ elif pm.is_rhel_based:
234
+ pm.install(
235
+ name="Install chrony",
236
+ packages=["chrony"],
237
+ _sudo=True,
238
+ )
239
+ systemd.service(
240
+ name="Ensure chronyd is running and enabled",
241
+ service="chronyd",
242
+ running=True,
243
+ enabled=True,
244
+ _sudo=True,
245
+ )
246
+ server.shell(
247
+ name="Force NTP sync",
248
+ commands=["chronyc makestep"],
249
+ _sudo=True,
250
+ _retries=3,
251
+ _retry_delay=5,
252
+ )
253
+ else:
254
+ raise NotImplementedError(f"NTP configuration is not supported for distro: {pm.distro_name}")
255
+
256
+
257
+ def _set_timezone(system: SystemMold) -> None:
258
+ """Set system timezone."""
259
+
260
+ server.timezone(
261
+ name=f"Set system timezone to {system.timezone}",
262
+ timezone=system.timezone,
263
+ _sudo=True,
264
+ )
265
+
266
+
267
+ def _ensure_ipv6_stack() -> None:
268
+ """Ensure the kernel IPv6 stack is not disabled at boot."""
269
+
270
+ grub_path = "/etc/default/grub"
271
+ grub_contents = host.get_fact(FileContents, path=grub_path)
272
+
273
+ if not grub_contents or not any("ipv6.disable=" in line for line in grub_contents):
274
+ return
275
+
276
+ remove_op = files.replace(
277
+ name="Remove ipv6.disable=... from GRUB kernel command lines",
278
+ path=grub_path,
279
+ text=r"""(^|[ "])ipv6\.disable=[^"']*""",
280
+ replace=r"\1",
281
+ extended_regex=True,
282
+ flags=["g"],
283
+ _sudo=True,
284
+ )
285
+
286
+ collapse_op = files.replace(
287
+ name="Collapse repeated spaces in GRUB_CMDLINE_LINUX* values",
288
+ path=grub_path,
289
+ text=r'(GRUB_CMDLINE_LINUX(_DEFAULT)?="[^"]*?) {2,}([^"]*?")',
290
+ replace=r"\1 \2",
291
+ extended_regex=True,
292
+ flags=["g"],
293
+ _sudo=True,
294
+ )
295
+
296
+ if remove_op.will_change or collapse_op.will_change:
297
+ _update_grub_config()
298
+
299
+
300
+ def _update_grub_config() -> None:
301
+ pm = get_pm()
302
+ if pm.is_debian_based:
303
+ server.shell(
304
+ name="Regenerate GRUB config (update-grub)",
305
+ commands=["update-grub"],
306
+ _sudo=True,
307
+ )
308
+ elif pm.is_rhel_based:
309
+ # try most common locations; failures for non-applicable paths are ignored.
310
+ server.shell(
311
+ name="Regenerate GRUB config (grub2-mkconfig)",
312
+ commands=[
313
+ (
314
+ "grub2-mkconfig -o /boot/grub2/grub.cfg 2>/dev/null || "
315
+ "grub2-mkconfig -o /boot/efi/EFI/redhat/grub.cfg 2>/dev/null || "
316
+ "grub2-mkconfig -o /boot/efi/EFI/centos/grub.cfg 2>/dev/null || "
317
+ "grub2-mkconfig -o /boot/efi/EFI/fedora/grub.cfg 2>/dev/null || "
318
+ "grub2-mkconfig -o /boot/efi/EFI/rocky/grub.cfg 2>/dev/null || "
319
+ "grub2-mkconfig -o /boot/efi/EFI/alma/grub.cfg 2>/dev/null || "
320
+ "true"
321
+ )
322
+ ],
323
+ _sudo=True,
324
+ )
325
+
326
+
327
+ deploy_base_system()