More refactoring, support hiera and multi site mode for Puppet
This commit is contained in:
parent
ed9ec6893a
commit
20cc48e1ce
18 changed files with 1647 additions and 1189 deletions
275
enroll/capture.py
Normal file
275
enroll/capture.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from .fsutil import stat_triplet
|
||||
from .harvest_types import ExcludedFile, ManagedFile, ManagedLink
|
||||
from .ignore import IgnorePolicy
|
||||
from .pathfilter import PathFilter
|
||||
|
||||
|
||||
def files_differ(a: str, b: str, *, max_bytes: int = 2_000_000) -> bool:
|
||||
"""Return True if file ``a`` differs from file ``b``.
|
||||
|
||||
Best-effort and conservative: unreadable/missing baselines, non-regular
|
||||
files, and unexpectedly large files are treated as different so callers err
|
||||
on the side of preserving user state.
|
||||
"""
|
||||
|
||||
try:
|
||||
st_a = os.stat(a, follow_symlinks=True)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
if not stat.S_ISREG(st_a.st_mode):
|
||||
return True
|
||||
|
||||
try:
|
||||
st_b = os.stat(b, follow_symlinks=True)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
if not stat.S_ISREG(st_b.st_mode):
|
||||
return True
|
||||
|
||||
if st_a.st_size != st_b.st_size:
|
||||
return True
|
||||
|
||||
if st_a.st_size > max_bytes:
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(a, "rb") as fa, open(b, "rb") as fb:
|
||||
while True:
|
||||
ca = fa.read(1024 * 64)
|
||||
cb = fb.read(1024 * 64)
|
||||
if ca != cb:
|
||||
return True
|
||||
if not ca:
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def copy_into_bundle(
|
||||
bundle_dir: str, role_name: str, abs_path: str, src_rel: str
|
||||
) -> None:
|
||||
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.copy2(abs_path, dst)
|
||||
|
||||
|
||||
def capture_file(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role_name: str,
|
||||
abs_path: str,
|
||||
reason: str,
|
||||
policy: IgnorePolicy,
|
||||
path_filter: PathFilter,
|
||||
managed_out: List[ManagedFile],
|
||||
excluded_out: List[ExcludedFile],
|
||||
seen_role: Optional[Set[str]] = None,
|
||||
seen_global: Optional[Set[str]] = None,
|
||||
metadata: Optional[tuple[str, str, str]] = None,
|
||||
) -> bool:
|
||||
"""Try to capture a single file into the bundle.
|
||||
|
||||
Returns True if the file was copied and appended to ``managed_out``.
|
||||
``seen_role`` de-duplicates within a role; ``seen_global`` de-duplicates
|
||||
across harvest stages so multiple generated roles do not manage one path.
|
||||
"""
|
||||
|
||||
if seen_global is not None and abs_path in seen_global:
|
||||
return False
|
||||
if seen_role is not None and abs_path in seen_role:
|
||||
return False
|
||||
|
||||
def _mark_seen() -> None:
|
||||
if seen_role is not None:
|
||||
seen_role.add(abs_path)
|
||||
if seen_global is not None:
|
||||
seen_global.add(abs_path)
|
||||
|
||||
if path_filter.is_excluded(abs_path):
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
deny = policy.deny_reason(abs_path)
|
||||
if deny:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
try:
|
||||
owner, group, mode = (
|
||||
metadata if metadata is not None else stat_triplet(abs_path)
|
||||
)
|
||||
except OSError:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
src_rel = abs_path.lstrip("/")
|
||||
try:
|
||||
copy_into_bundle(bundle_dir, role_name, abs_path, src_rel)
|
||||
except OSError:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
managed_out.append(
|
||||
ManagedFile(
|
||||
path=abs_path,
|
||||
src_rel=src_rel,
|
||||
owner=owner,
|
||||
group=group,
|
||||
mode=mode,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
_mark_seen()
|
||||
return True
|
||||
|
||||
|
||||
USER_SHELL_DOTFILES_WITH_SKEL_BASELINE = [
|
||||
(".bashrc", "user_shell_rc"),
|
||||
(".profile", "user_profile"),
|
||||
(".bash_logout", "user_shell_logout"),
|
||||
]
|
||||
|
||||
USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE = [
|
||||
(".bash_aliases", "user_shell_aliases"),
|
||||
]
|
||||
|
||||
|
||||
def capture_user_shell_dotfiles(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role_name: str,
|
||||
home: str,
|
||||
skel_dir: str,
|
||||
enabled: bool,
|
||||
policy: IgnorePolicy,
|
||||
path_filter: PathFilter,
|
||||
managed_out: List[ManagedFile],
|
||||
excluded_out: List[ExcludedFile],
|
||||
seen_role: Optional[Set[str]],
|
||||
seen_global: Optional[Set[str]],
|
||||
) -> int:
|
||||
"""Capture selected per-user shell dotfiles when explicitly enabled."""
|
||||
|
||||
if not enabled:
|
||||
return 0
|
||||
|
||||
home = (home or "").rstrip("/")
|
||||
if not home or not home.startswith("/"):
|
||||
return 0
|
||||
|
||||
captured = 0
|
||||
max_compare_bytes = int(getattr(policy, "max_file_bytes", 256_000))
|
||||
|
||||
for rel, reason in USER_SHELL_DOTFILES_WITH_SKEL_BASELINE:
|
||||
upath = os.path.join(home, rel)
|
||||
if not os.path.isfile(upath) or os.path.islink(upath):
|
||||
continue
|
||||
skel_path = os.path.join(skel_dir, rel)
|
||||
if not files_differ(upath, skel_path, max_bytes=max_compare_bytes):
|
||||
continue
|
||||
if capture_file(
|
||||
bundle_dir=bundle_dir,
|
||||
role_name=role_name,
|
||||
abs_path=upath,
|
||||
reason=reason,
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed_out,
|
||||
excluded_out=excluded_out,
|
||||
seen_role=seen_role,
|
||||
seen_global=seen_global,
|
||||
):
|
||||
captured += 1
|
||||
|
||||
for rel, reason in USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE:
|
||||
upath = os.path.join(home, rel)
|
||||
if not os.path.isfile(upath) or os.path.islink(upath):
|
||||
continue
|
||||
if capture_file(
|
||||
bundle_dir=bundle_dir,
|
||||
role_name=role_name,
|
||||
abs_path=upath,
|
||||
reason=reason,
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed_out,
|
||||
excluded_out=excluded_out,
|
||||
seen_role=seen_role,
|
||||
seen_global=seen_global,
|
||||
):
|
||||
captured += 1
|
||||
|
||||
return captured
|
||||
|
||||
|
||||
def capture_link(
|
||||
*,
|
||||
role_name: str,
|
||||
abs_path: str,
|
||||
reason: str,
|
||||
policy: IgnorePolicy,
|
||||
path_filter: PathFilter,
|
||||
managed_out: List[ManagedLink],
|
||||
excluded_out: List[ExcludedFile],
|
||||
seen_role: Optional[Set[str]] = None,
|
||||
seen_global: Optional[Set[str]] = None,
|
||||
) -> bool:
|
||||
"""Record a symlink for later materialisation by the manifest renderer."""
|
||||
|
||||
if seen_global is not None and abs_path in seen_global:
|
||||
return False
|
||||
if seen_role is not None and abs_path in seen_role:
|
||||
return False
|
||||
|
||||
def _mark_seen() -> None:
|
||||
if seen_role is not None:
|
||||
seen_role.add(abs_path)
|
||||
if seen_global is not None:
|
||||
seen_global.add(abs_path)
|
||||
|
||||
if path_filter.is_excluded(abs_path):
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
deny_link = getattr(policy, "deny_reason_link", None)
|
||||
if callable(deny_link):
|
||||
deny = deny_link(abs_path)
|
||||
else:
|
||||
deny = policy.deny_reason(abs_path)
|
||||
if deny in ("not_regular_file", "not_file", "not_regular"):
|
||||
deny = None
|
||||
|
||||
if deny:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
if not os.path.islink(abs_path):
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="not_symlink"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
try:
|
||||
target = os.readlink(abs_path)
|
||||
except OSError:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
managed_out.append(ManagedLink(path=abs_path, target=target, reason=reason))
|
||||
_mark_seen()
|
||||
return True
|
||||
1025
enroll/harvest.py
1025
enroll/harvest.py
File diff suppressed because it is too large
Load diff
|
|
@ -4,8 +4,10 @@ import os
|
|||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from .. import harvest as h
|
||||
from ..harvest import ExcludedFile, ManagedFile, PackageSnapshot
|
||||
from ..capture import capture_file
|
||||
from ..harvest_types import ExcludedFile, ManagedFile, PackageSnapshot
|
||||
from ..package_hints import package_section_from_installations
|
||||
from ..system_paths import iter_matching_files
|
||||
from .context import HarvestCollector
|
||||
|
||||
|
||||
|
|
@ -97,10 +99,10 @@ class CronLogrotateCollector(HarvestCollector):
|
|||
seen: Set[str] = set()
|
||||
|
||||
for spec in _CRON_CAPTURE_GLOBS:
|
||||
for path in h._iter_matching_files(spec):
|
||||
for path in iter_matching_files(spec):
|
||||
if not os.path.isfile(path) or os.path.islink(path):
|
||||
continue
|
||||
h._capture_file(
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=self.cron_role_name,
|
||||
abs_path=path,
|
||||
|
|
@ -116,7 +118,7 @@ class CronLogrotateCollector(HarvestCollector):
|
|||
return PackageSnapshot(
|
||||
package=cron_pkg,
|
||||
role_name=self.cron_role_name,
|
||||
section=h._package_section_from_installations(
|
||||
section=package_section_from_installations(
|
||||
self.context.installed_pkgs.get(cron_pkg, [])
|
||||
),
|
||||
managed_files=managed,
|
||||
|
|
@ -131,10 +133,10 @@ class CronLogrotateCollector(HarvestCollector):
|
|||
seen: Set[str] = set()
|
||||
|
||||
for spec in _LOGROTATE_CAPTURE_GLOBS:
|
||||
for path in h._iter_matching_files(spec):
|
||||
for path in iter_matching_files(spec):
|
||||
if not os.path.isfile(path) or os.path.islink(path):
|
||||
continue
|
||||
h._capture_file(
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=self.logrotate_role_name,
|
||||
abs_path=path,
|
||||
|
|
@ -150,7 +152,7 @@ class CronLogrotateCollector(HarvestCollector):
|
|||
return PackageSnapshot(
|
||||
package=logrotate_pkg,
|
||||
role_name=self.logrotate_role_name,
|
||||
section=h._package_section_from_installations(
|
||||
section=package_section_from_installations(
|
||||
self.context.installed_pkgs.get(logrotate_pkg, [])
|
||||
),
|
||||
managed_files=managed,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@ from __future__ import annotations
|
|||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Set
|
||||
|
||||
from .. import harvest as h
|
||||
from ..harvest import AptConfigSnapshot, DnfConfigSnapshot, ExcludedFile, ManagedFile
|
||||
from ..capture import capture_file
|
||||
from ..harvest_types import (
|
||||
AptConfigSnapshot,
|
||||
DnfConfigSnapshot,
|
||||
ExcludedFile,
|
||||
ManagedFile,
|
||||
)
|
||||
from ..system_paths import iter_apt_capture_paths, iter_dnf_capture_paths
|
||||
from .context import HarvestCollector, HarvestContext
|
||||
|
||||
|
||||
|
|
@ -36,8 +42,8 @@ class PackageManagerConfigCollector(HarvestCollector):
|
|||
|
||||
if self.context.backend.name == "dpkg":
|
||||
apt_role_seen = self.seen_by_role.setdefault(apt_role_name, set())
|
||||
for path, reason in h._iter_apt_capture_paths():
|
||||
h._capture_file(
|
||||
for path, reason in iter_apt_capture_paths():
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=apt_role_name,
|
||||
abs_path=path,
|
||||
|
|
@ -51,8 +57,8 @@ class PackageManagerConfigCollector(HarvestCollector):
|
|||
)
|
||||
elif self.context.backend.name == "rpm":
|
||||
dnf_role_seen = self.seen_by_role.setdefault(dnf_role_name, set())
|
||||
for path, reason in h._iter_dnf_capture_paths():
|
||||
h._capture_file(
|
||||
for path, reason in iter_dnf_capture_paths():
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=dnf_role_name,
|
||||
abs_path=path,
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@ import os
|
|||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from .. import harvest as h
|
||||
from ..harvest import (
|
||||
from ..capture import capture_file
|
||||
from ..harvest_types import (
|
||||
ExcludedFile,
|
||||
ExtraPathsSnapshot,
|
||||
ManagedDir,
|
||||
ManagedFile,
|
||||
UsrLocalCustomSnapshot,
|
||||
)
|
||||
from ..system_paths import MAX_FILES_CAP
|
||||
from ..pathfilter import expand_includes
|
||||
from .context import HarvestCollector, HarvestContext
|
||||
|
||||
|
|
@ -38,13 +40,13 @@ class UsrLocalCustomCollector(HarvestCollector):
|
|||
self._scan_tree(
|
||||
"/usr/local/etc",
|
||||
require_executable=False,
|
||||
cap=h.MAX_FILES_CAP,
|
||||
cap=MAX_FILES_CAP,
|
||||
reason="usr_local_etc_custom",
|
||||
)
|
||||
self._scan_tree(
|
||||
"/usr/local/bin",
|
||||
require_executable=True,
|
||||
cap=h.MAX_FILES_CAP,
|
||||
cap=MAX_FILES_CAP,
|
||||
reason="usr_local_bin_script",
|
||||
)
|
||||
return UsrLocalCustomSnapshot(
|
||||
|
|
@ -86,7 +88,7 @@ class UsrLocalCustomCollector(HarvestCollector):
|
|||
except ValueError:
|
||||
continue
|
||||
|
||||
if h._capture_file(
|
||||
if capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=self.role_name,
|
||||
abs_path=path,
|
||||
|
|
@ -147,7 +149,7 @@ class ExtraPathsCollector(HarvestCollector):
|
|||
files, inc_notes = expand_includes(
|
||||
self.context.path_filter.iter_include_patterns(),
|
||||
exclude=self.context.path_filter,
|
||||
max_files=h.MAX_FILES_CAP,
|
||||
max_files=MAX_FILES_CAP,
|
||||
)
|
||||
included_files = files
|
||||
self.notes.extend(inc_notes)
|
||||
|
|
@ -156,7 +158,7 @@ class ExtraPathsCollector(HarvestCollector):
|
|||
for path in included_files:
|
||||
if path in self.already_all:
|
||||
continue
|
||||
if h._capture_file(
|
||||
if capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=self.role_name,
|
||||
abs_path=path,
|
||||
|
|
@ -198,9 +200,9 @@ class ExtraPathsCollector(HarvestCollector):
|
|||
if not os.path.isdir(root) or os.path.islink(root):
|
||||
return
|
||||
for dirpath, dirnames, _ in os.walk(root, followlinks=False):
|
||||
if len(self.managed_dirs) >= h.MAX_FILES_CAP:
|
||||
if len(self.managed_dirs) >= MAX_FILES_CAP:
|
||||
self.notes.append(
|
||||
f"Reached directory cap ({h.MAX_FILES_CAP}) while scanning {root}."
|
||||
f"Reached directory cap ({MAX_FILES_CAP}) while scanning {root}."
|
||||
)
|
||||
return
|
||||
dirpath = os.path.normpath(dirpath)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from dataclasses import dataclass
|
|||
from typing import List, Optional
|
||||
|
||||
from .. import harvest as h
|
||||
from ..harvest import FirewallRuntimeSnapshot, SysctlSnapshot
|
||||
from ..harvest_types import FirewallRuntimeSnapshot, SysctlSnapshot
|
||||
from .context import HarvestCollector, HarvestContext
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,23 @@ from dataclasses import dataclass
|
|||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from .. import harvest as h
|
||||
from ..harvest import ExcludedFile, ManagedFile, PackageSnapshot, ServiceSnapshot
|
||||
from ..capture import capture_file, capture_link
|
||||
from ..harvest_types import ExcludedFile, ManagedFile, PackageSnapshot, ServiceSnapshot
|
||||
from ..package_hints import (
|
||||
SHARED_ETC_TOPDIRS,
|
||||
add_pkgs_from_etc_topdirs,
|
||||
hint_names,
|
||||
maybe_add_specific_paths,
|
||||
package_section_from_installations,
|
||||
role_name_from_pkg,
|
||||
role_name_from_unit,
|
||||
)
|
||||
from ..system_paths import (
|
||||
MAX_UNOWNED_FILES_PER_ROLE,
|
||||
is_confish,
|
||||
scan_unowned_under_roots,
|
||||
topdirs_for_package,
|
||||
)
|
||||
from ..systemd import UnitQueryError
|
||||
from .context import HarvestCollector, HarvestContext
|
||||
from .cron_logrotate import CronLogrotateCollector, _is_cron_path, _is_logrotate_path
|
||||
|
|
@ -80,7 +96,7 @@ class ServicePackageCollector(HarvestCollector):
|
|||
enabled_services = [
|
||||
u
|
||||
for u in enabled_services
|
||||
if h._role_name_from_unit(u) not in blocked_roles
|
||||
if role_name_from_unit(u) not in blocked_roles
|
||||
]
|
||||
enabled_set = set(enabled_services)
|
||||
|
||||
|
|
@ -106,15 +122,15 @@ class ServicePackageCollector(HarvestCollector):
|
|||
}
|
||||
|
||||
for unit in sorted(enabled_services, key=service_sort_key):
|
||||
role = h._role_name_from_unit(unit)
|
||||
role = role_name_from_unit(unit)
|
||||
parent_unit = parent_unit_for.get(unit)
|
||||
parent_role = h._role_name_from_unit(parent_unit) if parent_unit else None
|
||||
parent_role = role_name_from_unit(parent_unit) if parent_unit else None
|
||||
|
||||
try:
|
||||
ui = h.get_unit_info(unit)
|
||||
except UnitQueryError as e:
|
||||
self.service_role_aliases.setdefault(
|
||||
role, h._hint_names(unit, set()) | {role}
|
||||
role, hint_names(unit, set()) | {role}
|
||||
)
|
||||
self.seen_by_role.setdefault(role, set())
|
||||
managed = self.managed_by_role.setdefault(role, [])
|
||||
|
|
@ -164,11 +180,11 @@ class ServicePackageCollector(HarvestCollector):
|
|||
elif env_file.startswith("/etc/") and os.path.isfile(env_file):
|
||||
candidates[env_file] = "systemd_envfile"
|
||||
|
||||
hints = h._hint_names(unit, pkgs)
|
||||
h._add_pkgs_from_etc_topdirs(hints, self.context.topdir_to_pkgs, pkgs)
|
||||
hints = hint_names(unit, pkgs)
|
||||
add_pkgs_from_etc_topdirs(hints, self.context.topdir_to_pkgs, pkgs)
|
||||
self.service_role_aliases[role] = set(hints) | set(pkgs) | {role}
|
||||
|
||||
for sp in h._maybe_add_specific_paths(hints, backend):
|
||||
for sp in maybe_add_specific_paths(hints, backend):
|
||||
if not os.path.exists(sp):
|
||||
continue
|
||||
if sp in self.context.etc_owner_map:
|
||||
|
|
@ -193,26 +209,26 @@ class ServicePackageCollector(HarvestCollector):
|
|||
confish_roots: List[str] = []
|
||||
for hint in hints:
|
||||
roots_for_hint = [f"/etc/{hint}", f"/etc/{hint}.d"]
|
||||
if hint in h.SHARED_ETC_TOPDIRS:
|
||||
if hint in SHARED_ETC_TOPDIRS:
|
||||
confish_roots.extend(roots_for_hint)
|
||||
else:
|
||||
any_roots.extend(roots_for_hint)
|
||||
|
||||
found: List[str] = []
|
||||
found.extend(
|
||||
h._scan_unowned_under_roots(
|
||||
scan_unowned_under_roots(
|
||||
any_roots,
|
||||
self.context.owned_etc,
|
||||
limit=h.MAX_UNOWNED_FILES_PER_ROLE,
|
||||
limit=MAX_UNOWNED_FILES_PER_ROLE,
|
||||
confish_only=False,
|
||||
)
|
||||
)
|
||||
if len(found) < h.MAX_UNOWNED_FILES_PER_ROLE:
|
||||
if len(found) < MAX_UNOWNED_FILES_PER_ROLE:
|
||||
found.extend(
|
||||
h._scan_unowned_under_roots(
|
||||
scan_unowned_under_roots(
|
||||
confish_roots,
|
||||
self.context.owned_etc,
|
||||
limit=h.MAX_UNOWNED_FILES_PER_ROLE - len(found),
|
||||
limit=MAX_UNOWNED_FILES_PER_ROLE - len(found),
|
||||
confish_only=True,
|
||||
)
|
||||
)
|
||||
|
|
@ -236,7 +252,7 @@ class ServicePackageCollector(HarvestCollector):
|
|||
dest_managed = self.managed_by_role.setdefault(dest_role, [])
|
||||
dest_excluded = self.excluded_by_role.setdefault(dest_role, [])
|
||||
dest_seen = self.seen_by_role.setdefault(dest_role, set())
|
||||
h._capture_file(
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=dest_role,
|
||||
abs_path=path,
|
||||
|
|
@ -305,7 +321,7 @@ class ServicePackageCollector(HarvestCollector):
|
|||
if snap is not None:
|
||||
role_seen = self.seen_by_role.setdefault(snap.role_name, set())
|
||||
for path in timer_paths:
|
||||
h._capture_file(
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=snap.role_name,
|
||||
abs_path=path,
|
||||
|
|
@ -374,7 +390,7 @@ class ServicePackageCollector(HarvestCollector):
|
|||
manual_pkgs_skipped.append(pkg)
|
||||
continue
|
||||
|
||||
role = h._role_name_from_pkg(pkg)
|
||||
role = role_name_from_pkg(pkg)
|
||||
notes: List[str] = []
|
||||
excluded: List[ExcludedFile] = []
|
||||
managed: List[ManagedFile] = []
|
||||
|
|
@ -395,19 +411,19 @@ class ServicePackageCollector(HarvestCollector):
|
|||
continue
|
||||
candidates.setdefault(path, reason)
|
||||
|
||||
topdirs = h._topdirs_for_package(pkg, self.context.pkg_to_etc_paths)
|
||||
topdirs = topdirs_for_package(pkg, self.context.pkg_to_etc_paths)
|
||||
roots: List[str] = []
|
||||
for topdir in sorted(topdirs):
|
||||
if topdir in h.SHARED_ETC_TOPDIRS:
|
||||
if topdir in SHARED_ETC_TOPDIRS:
|
||||
continue
|
||||
if backend.is_pkg_config_path(
|
||||
f"/etc/{topdir}/"
|
||||
) or backend.is_pkg_config_path(f"/etc/{topdir}"):
|
||||
continue
|
||||
roots.extend([f"/etc/{topdir}", f"/etc/{topdir}.d"])
|
||||
roots.extend(h._maybe_add_specific_paths(set(topdirs), backend))
|
||||
roots.extend(maybe_add_specific_paths(set(topdirs), backend))
|
||||
|
||||
for pth in h._scan_unowned_under_roots(
|
||||
for pth in scan_unowned_under_roots(
|
||||
[r for r in roots if os.path.isdir(r)],
|
||||
self.context.owned_etc,
|
||||
confish_only=False,
|
||||
|
|
@ -416,12 +432,12 @@ class ServicePackageCollector(HarvestCollector):
|
|||
|
||||
for root in roots:
|
||||
if os.path.isfile(root) and not os.path.islink(root):
|
||||
if root not in self.context.owned_etc and h._is_confish(root):
|
||||
if root not in self.context.owned_etc and is_confish(root):
|
||||
candidates.setdefault(root, "custom_specific_path")
|
||||
|
||||
role_seen = self.seen_by_role.setdefault(role, set())
|
||||
for path, reason in sorted(candidates.items()):
|
||||
h._capture_file(
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=role,
|
||||
abs_path=path,
|
||||
|
|
@ -445,7 +461,7 @@ class ServicePackageCollector(HarvestCollector):
|
|||
PackageSnapshot(
|
||||
package=pkg,
|
||||
role_name=role,
|
||||
section=h._package_section_from_installations(
|
||||
section=package_section_from_installations(
|
||||
self.context.installed_pkgs.get(pkg, [])
|
||||
),
|
||||
managed_files=managed,
|
||||
|
|
@ -490,7 +506,7 @@ class ServicePackageCollector(HarvestCollector):
|
|||
for pth in sorted(glob.glob(os.path.join(directory, "*"))):
|
||||
if not os.path.islink(pth):
|
||||
continue
|
||||
h._capture_link(
|
||||
capture_link(
|
||||
role_name=role_name,
|
||||
abs_path=pth,
|
||||
reason="enabled_symlink",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ from dataclasses import asdict, dataclass
|
|||
from typing import Any, Dict, List, Set
|
||||
|
||||
from .. import harvest as h
|
||||
from ..harvest import (
|
||||
from ..capture import capture_file, capture_user_shell_dotfiles
|
||||
from ..harvest_types import (
|
||||
ExcludedFile,
|
||||
FlatpakSnapshot,
|
||||
ManagedFile,
|
||||
|
|
@ -104,7 +105,7 @@ class UsersCollector(HarvestCollector):
|
|||
if ssh_file.endswith("/authorized_keys")
|
||||
else "ssh_public_key"
|
||||
)
|
||||
h._capture_file(
|
||||
capture_file(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=users_role_name,
|
||||
abs_path=ssh_file,
|
||||
|
|
@ -121,7 +122,7 @@ class UsersCollector(HarvestCollector):
|
|||
# often contain exported tokens or aliases/functions with embedded secrets.
|
||||
home = (user.home or "").rstrip("/")
|
||||
if home and home.startswith("/"):
|
||||
h._capture_user_shell_dotfiles(
|
||||
capture_user_shell_dotfiles(
|
||||
bundle_dir=self.context.bundle_dir,
|
||||
role_name=users_role_name,
|
||||
home=home,
|
||||
|
|
|
|||
165
enroll/harvest_types.py
Normal file
165
enroll/harvest_types.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManagedFile:
|
||||
path: str
|
||||
src_rel: str
|
||||
owner: str
|
||||
group: str
|
||||
mode: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManagedLink:
|
||||
"""A symlink we want to materialise on the target host.
|
||||
|
||||
For configuration enablement patterns (e.g. sites-enabled), the symlink is
|
||||
meaningful state even when the link target is captured elsewhere.
|
||||
"""
|
||||
|
||||
path: str
|
||||
target: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManagedDir:
|
||||
path: str
|
||||
owner: str
|
||||
group: str
|
||||
mode: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExcludedFile:
|
||||
path: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceSnapshot:
|
||||
unit: str
|
||||
role_name: str
|
||||
packages: List[str]
|
||||
active_state: Optional[str]
|
||||
sub_state: Optional[str]
|
||||
unit_file_state: Optional[str]
|
||||
condition_result: Optional[str]
|
||||
managed_dirs: List[ManagedDir] = field(default_factory=list)
|
||||
managed_files: List[ManagedFile] = field(default_factory=list)
|
||||
managed_links: List[ManagedLink] = field(default_factory=list)
|
||||
excluded: List[ExcludedFile] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PackageSnapshot:
|
||||
package: str
|
||||
role_name: str
|
||||
section: Optional[str] = None
|
||||
managed_dirs: List[ManagedDir] = field(default_factory=list)
|
||||
managed_files: List[ManagedFile] = field(default_factory=list)
|
||||
managed_links: List[ManagedLink] = field(default_factory=list)
|
||||
excluded: List[ExcludedFile] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
has_config: bool = True # False if package has no config/systemd/cron files
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsersSnapshot:
|
||||
role_name: str
|
||||
users: List[dict]
|
||||
managed_dirs: List[ManagedDir] = field(default_factory=list)
|
||||
managed_files: List[ManagedFile] = field(default_factory=list)
|
||||
excluded: List[ExcludedFile] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
user_flatpaks: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
user_flatpak_remotes: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlatpakSnapshot:
|
||||
role_name: str
|
||||
system_flatpaks: List[Dict[str, Any]] = field(default_factory=list)
|
||||
remotes: List[Dict[str, Any]] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapSnapshot:
|
||||
role_name: str
|
||||
system_snaps: List[Dict[str, Any]] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AptConfigSnapshot:
|
||||
role_name: str
|
||||
managed_dirs: List[ManagedDir] = field(default_factory=list)
|
||||
managed_files: List[ManagedFile] = field(default_factory=list)
|
||||
excluded: List[ExcludedFile] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DnfConfigSnapshot:
|
||||
role_name: str
|
||||
managed_dirs: List[ManagedDir] = field(default_factory=list)
|
||||
managed_files: List[ManagedFile] = field(default_factory=list)
|
||||
excluded: List[ExcludedFile] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EtcCustomSnapshot:
|
||||
role_name: str
|
||||
managed_dirs: List[ManagedDir] = field(default_factory=list)
|
||||
managed_files: List[ManagedFile] = field(default_factory=list)
|
||||
excluded: List[ExcludedFile] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsrLocalCustomSnapshot:
|
||||
role_name: str
|
||||
managed_dirs: List[ManagedDir] = field(default_factory=list)
|
||||
managed_files: List[ManagedFile] = field(default_factory=list)
|
||||
excluded: List[ExcludedFile] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtraPathsSnapshot:
|
||||
role_name: str
|
||||
include_patterns: List[str] = field(default_factory=list)
|
||||
exclude_patterns: List[str] = field(default_factory=list)
|
||||
managed_dirs: List[ManagedDir] = field(default_factory=list)
|
||||
managed_files: List[ManagedFile] = field(default_factory=list)
|
||||
managed_links: List[ManagedLink] = field(default_factory=list)
|
||||
excluded: List[ExcludedFile] = field(default_factory=list)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirewallRuntimeSnapshot:
|
||||
role_name: str
|
||||
packages: List[str] = field(default_factory=list)
|
||||
ipset_save: Optional[str] = None
|
||||
ipset_sets: List[str] = field(default_factory=list)
|
||||
iptables_v4_save: Optional[str] = None
|
||||
iptables_v6_save: Optional[str] = None
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SysctlSnapshot:
|
||||
role_name: str
|
||||
managed_files: List[ManagedFile] = field(default_factory=list)
|
||||
parameters: Dict[str, str] = field(default_factory=dict)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
126
enroll/package_hints.py
Normal file
126
enroll/package_hints.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from .role_names import avoid_reserved_role_name
|
||||
|
||||
|
||||
# Directories that are shared across many packages. Never attribute all unowned
|
||||
# files in these trees to one single package.
|
||||
SHARED_ETC_TOPDIRS = {
|
||||
"apparmor.d",
|
||||
"apt",
|
||||
"cron.d",
|
||||
"cron.daily",
|
||||
"cron.weekly",
|
||||
"cron.monthly",
|
||||
"cron.hourly",
|
||||
"default",
|
||||
"init.d",
|
||||
"logrotate.d",
|
||||
"modprobe.d",
|
||||
"network",
|
||||
"pam.d",
|
||||
"ssh",
|
||||
"ssl",
|
||||
"sudoers.d",
|
||||
"sysctl.d",
|
||||
"systemd",
|
||||
# RPM-family shared trees
|
||||
"dnf",
|
||||
"yum",
|
||||
"yum.repos.d",
|
||||
"sysconfig",
|
||||
"pki",
|
||||
"firewalld",
|
||||
}
|
||||
|
||||
|
||||
def safe_name(s: str) -> str:
|
||||
out: List[str] = []
|
||||
for ch in s:
|
||||
out.append(ch if ch.isalnum() or ch in ("_", "-") else "_")
|
||||
return "".join(out).replace("-", "_")
|
||||
|
||||
|
||||
def role_id(raw: str) -> str:
|
||||
# normalise separators first
|
||||
s = re.sub(r"[^A-Za-z0-9]+", "_", raw)
|
||||
# split CamelCase -> snake_case
|
||||
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
||||
s = s.lower()
|
||||
s = re.sub(r"_+", "_", s).strip("_")
|
||||
if not re.match(r"^[a-z_]", s):
|
||||
s = "r_" + s
|
||||
return s
|
||||
|
||||
|
||||
def role_name_from_unit(unit: str) -> str:
|
||||
base = role_id(unit.removesuffix(".service"))
|
||||
return avoid_reserved_role_name(safe_name(base), prefix="service")
|
||||
|
||||
|
||||
def role_name_from_pkg(pkg: str) -> str:
|
||||
return avoid_reserved_role_name(safe_name(pkg), prefix="package")
|
||||
|
||||
|
||||
def package_section_from_installations(
|
||||
installs: List[Dict[str, str]],
|
||||
) -> Optional[str]:
|
||||
"""Return a stable package grouping label from installed package metadata."""
|
||||
|
||||
values: Set[str] = set()
|
||||
for inst in installs or []:
|
||||
value = (inst.get("section") or inst.get("group") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if value.lower() in {"(none)", "none", "unspecified"}:
|
||||
continue
|
||||
values.add(value)
|
||||
|
||||
if not values:
|
||||
return None
|
||||
return sorted(values)[0]
|
||||
|
||||
|
||||
def hint_names(unit: str, pkgs: Set[str]) -> Set[str]:
|
||||
base = unit.removesuffix(".service")
|
||||
hints = {base}
|
||||
if "@" in base:
|
||||
hints.add(base.split("@", 1)[0])
|
||||
hints |= set(pkgs)
|
||||
hints |= {h.split(".", 1)[0] for h in list(hints) if "." in h}
|
||||
return {h for h in hints if h}
|
||||
|
||||
|
||||
def add_pkgs_from_etc_topdirs(
|
||||
hints: Set[str], topdir_to_pkgs: Dict[str, Set[str]], pkgs: Set[str]
|
||||
) -> None:
|
||||
"""Expand a service's package set using package-owned /etc top-level dirs."""
|
||||
|
||||
for h in hints:
|
||||
for top in (h, f"{h}.d"):
|
||||
if top in SHARED_ETC_TOPDIRS:
|
||||
continue
|
||||
for p in topdir_to_pkgs.get(top, set()):
|
||||
pkgs.add(p)
|
||||
|
||||
|
||||
def maybe_add_specific_paths(hints: Set[str], backend) -> List[str]:
|
||||
# Delegate to backend-specific conventions (e.g. /etc/default on Debian,
|
||||
# /etc/sysconfig on Fedora/RHEL). Always include sysctl.d.
|
||||
try:
|
||||
return backend.specific_paths_for_hints(hints)
|
||||
except Exception:
|
||||
# Best-effort fallback (Debian-ish).
|
||||
paths: List[str] = []
|
||||
for h in hints:
|
||||
paths.extend(
|
||||
[
|
||||
f"/etc/default/{h}",
|
||||
f"/etc/init.d/{h}",
|
||||
f"/etc/sysctl.d/{h}.conf",
|
||||
]
|
||||
)
|
||||
return paths
|
||||
375
enroll/puppet.py
375
enroll/puppet.py
|
|
@ -4,7 +4,9 @@ import json
|
|||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from .cm import (
|
||||
CMModule,
|
||||
|
|
@ -87,6 +89,7 @@ class PuppetRole(CMModule):
|
|||
bundle_dir: str,
|
||||
artifact_role: str,
|
||||
module_files_dir: Path,
|
||||
file_prefix: Optional[str] = None,
|
||||
) -> None:
|
||||
for d in self.managed_dirs_from_snapshot(snap):
|
||||
path = str(d.get("path") or "").strip()
|
||||
|
|
@ -104,7 +107,11 @@ class PuppetRole(CMModule):
|
|||
if not path or not src_rel:
|
||||
continue
|
||||
module_rel = _copy_artifact(
|
||||
bundle_dir, artifact_role, src_rel, module_files_dir
|
||||
bundle_dir,
|
||||
artifact_role,
|
||||
src_rel,
|
||||
module_files_dir,
|
||||
dst_prefix=file_prefix,
|
||||
)
|
||||
if not module_rel:
|
||||
self.notes.append(
|
||||
|
|
@ -203,17 +210,23 @@ def _resource(
|
|||
|
||||
|
||||
def _copy_artifact(
|
||||
bundle_dir: str, role: str, src_rel: str, dst_files_dir: Path
|
||||
bundle_dir: str,
|
||||
role: str,
|
||||
src_rel: str,
|
||||
dst_files_dir: Path,
|
||||
*,
|
||||
dst_prefix: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if not role or not src_rel:
|
||||
return None
|
||||
src = Path(bundle_dir) / "artifacts" / role / src_rel
|
||||
if not src.is_file():
|
||||
return None
|
||||
dst = dst_files_dir / src_rel
|
||||
module_rel = Path(dst_prefix or "") / src_rel
|
||||
dst = dst_files_dir / module_rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
return Path(src_rel).as_posix()
|
||||
return module_rel.as_posix()
|
||||
|
||||
|
||||
def _source_uri(module_name: str, module_rel: str) -> str:
|
||||
|
|
@ -237,6 +250,21 @@ def _add_flatpak_snap_notes(roles: Dict[str, Any], out: Dict[str, PuppetRole]) -
|
|||
)
|
||||
|
||||
|
||||
def _node_data_filename(fqdn: str) -> str:
|
||||
"""Return a safe Hiera node-data filename for an FQDN/certname."""
|
||||
|
||||
name = str(fqdn or "").strip().replace("/", "_").replace("\\", "_")
|
||||
return f"{name or 'node'}.yaml"
|
||||
|
||||
|
||||
def _node_file_prefix(fqdn: str) -> str:
|
||||
"""Return a safe module-files prefix for node-specific artifacts."""
|
||||
|
||||
name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(fqdn or "").strip())
|
||||
name = name.strip("._-") or "node"
|
||||
return f"nodes/{name}"
|
||||
|
||||
|
||||
def _collect_puppet_roles(
|
||||
state: Dict[str, Any],
|
||||
bundle_dir: str,
|
||||
|
|
@ -248,6 +276,7 @@ def _collect_puppet_roles(
|
|||
roles = roles_from_state(state)
|
||||
inventory_packages = inventory_packages_from_state(state)
|
||||
use_common_modules = not fqdn and not no_common_roles
|
||||
node_file_prefix = _node_file_prefix(fqdn) if fqdn else None
|
||||
out: Dict[str, PuppetRole] = {}
|
||||
|
||||
def ensure_role(role_name: str) -> PuppetRole:
|
||||
|
|
@ -275,6 +304,7 @@ def _collect_puppet_roles(
|
|||
bundle_dir=bundle_dir,
|
||||
artifact_role=str(snap.get("role_name") or key),
|
||||
module_files_dir=module_files_dir,
|
||||
file_prefix=node_file_prefix,
|
||||
)
|
||||
|
||||
users_snap = roles.get("users") or {}
|
||||
|
|
@ -289,6 +319,7 @@ def _collect_puppet_roles(
|
|||
bundle_dir=bundle_dir,
|
||||
artifact_role=str(users_snap.get("role_name") or "users"),
|
||||
module_files_dir=modules_dir / prole.module_name / "files",
|
||||
file_prefix=node_file_prefix,
|
||||
)
|
||||
|
||||
for svc in roles.get("services", []) or []:
|
||||
|
|
@ -319,6 +350,7 @@ def _collect_puppet_roles(
|
|||
bundle_dir=bundle_dir,
|
||||
artifact_role=str(svc.get("role_name") or original_role_name),
|
||||
module_files_dir=modules_dir / prole.module_name / "files",
|
||||
file_prefix=node_file_prefix,
|
||||
)
|
||||
|
||||
for pkg in roles.get("packages", []) or []:
|
||||
|
|
@ -342,6 +374,7 @@ def _collect_puppet_roles(
|
|||
bundle_dir=bundle_dir,
|
||||
artifact_role=str(pkg.get("role_name") or original_role_name),
|
||||
module_files_dir=modules_dir / prole.module_name / "files",
|
||||
file_prefix=node_file_prefix,
|
||||
)
|
||||
|
||||
fw = roles.get("firewall_runtime") or {}
|
||||
|
|
@ -489,6 +522,164 @@ def _render_role_class(prole: PuppetRole) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _attrs_with_ensure(attrs: Dict[str, Any], ensure: str) -> Dict[str, Any]:
|
||||
out = {"ensure": ensure}
|
||||
out.update(attrs)
|
||||
return out
|
||||
|
||||
|
||||
def _role_hiera_values(prole: PuppetRole) -> Dict[str, Any]:
|
||||
"""Return Automatic Parameter Lookup data for one generated module."""
|
||||
|
||||
data: Dict[str, Any] = {}
|
||||
prefix = f"{prole.module_name}::"
|
||||
|
||||
if prole.packages:
|
||||
data[f"{prefix}packages"] = sorted(prole.packages)
|
||||
|
||||
if prole.groups:
|
||||
data[f"{prefix}groups"] = {
|
||||
group: {"ensure": "present"} for group in sorted(prole.groups)
|
||||
}
|
||||
|
||||
if prole.users:
|
||||
users: Dict[str, Dict[str, Any]] = {}
|
||||
for name in sorted(prole.users):
|
||||
user = prole.users[name]
|
||||
attrs: Dict[str, Any] = {"ensure": "present", "managehome": True}
|
||||
if user.get("uid") is not None:
|
||||
attrs["uid"] = user["uid"]
|
||||
if user.get("primary_group"):
|
||||
attrs["gid"] = user["primary_group"]
|
||||
if user.get("home"):
|
||||
attrs["home"] = user["home"]
|
||||
if user.get("shell"):
|
||||
attrs["shell"] = user["shell"]
|
||||
if user.get("gecos"):
|
||||
attrs["comment"] = user["gecos"]
|
||||
if user.get("supplementary_groups"):
|
||||
attrs["groups"] = list(user["supplementary_groups"])
|
||||
attrs["membership"] = "minimum"
|
||||
users[name] = attrs
|
||||
data[f"{prefix}users"] = users
|
||||
|
||||
if prole.dirs:
|
||||
data[f"{prefix}dirs"] = {
|
||||
path: _attrs_with_ensure(prole.dirs[path], "directory")
|
||||
for path in sorted(prole.dirs)
|
||||
}
|
||||
|
||||
if prole.files:
|
||||
data[f"{prefix}files"] = {
|
||||
path: _attrs_with_ensure(prole.files[path], "file")
|
||||
for path in sorted(prole.files)
|
||||
}
|
||||
|
||||
if prole.links:
|
||||
data[f"{prefix}links"] = {
|
||||
path: _attrs_with_ensure(prole.links[path], "link")
|
||||
for path in sorted(prole.links)
|
||||
}
|
||||
|
||||
if prole.services:
|
||||
data[f"{prefix}services"] = {
|
||||
name: {
|
||||
"ensure": prole.services[name].get("ensure") or "stopped",
|
||||
"enable": bool(prole.services[name].get("enable")),
|
||||
}
|
||||
for name in sorted(prole.services)
|
||||
}
|
||||
|
||||
if prole.notes:
|
||||
data[f"{prefix}notes"] = list(prole.notes)
|
||||
|
||||
if "/etc/sysctl.d/99-enroll.conf" in prole.files:
|
||||
data[f"{prefix}sysctl_apply"] = True
|
||||
data[f"{prefix}sysctl_ignore_apply_errors"] = True
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _render_hiera_role_class(prole: PuppetRole) -> str:
|
||||
"""Render a reusable, data-driven Puppet class for --fqdn/Hiera mode."""
|
||||
|
||||
lines: List[str] = [
|
||||
"# Generated by Enroll from harvest state.",
|
||||
"# Resource data is supplied by Hiera Automatic Parameter Lookup.",
|
||||
f"class {prole.module_name} (",
|
||||
" Array[String] $packages = [],",
|
||||
" Hash[String, Hash] $groups = {},",
|
||||
" Hash[String, Hash] $users = {},",
|
||||
" Hash[String, Hash] $dirs = {},",
|
||||
" Hash[String, Hash] $files = {},",
|
||||
" Hash[String, Hash] $links = {},",
|
||||
" Hash[String, Hash] $services = {},",
|
||||
" Array[String] $notes = [],",
|
||||
" Boolean $sysctl_apply = true,",
|
||||
" Boolean $sysctl_ignore_apply_errors = true,",
|
||||
") {",
|
||||
"",
|
||||
" $packages.each |String $package_name| {",
|
||||
" package { $package_name:",
|
||||
" ensure => 'installed',",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $groups.each |String $resource_title, Hash $attrs| {",
|
||||
" group { $resource_title:",
|
||||
" * => $attrs,",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $users.each |String $resource_title, Hash $attrs| {",
|
||||
" user { $resource_title:",
|
||||
" * => $attrs,",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $dirs.each |String $resource_title, Hash $attrs| {",
|
||||
" file { $resource_title:",
|
||||
" * => $attrs,",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $files.each |String $resource_title, Hash $attrs| {",
|
||||
" file { $resource_title:",
|
||||
" * => $attrs,",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $links.each |String $resource_title, Hash $attrs| {",
|
||||
" file { $resource_title:",
|
||||
" * => $attrs,",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $services.each |String $resource_title, Hash $attrs| {",
|
||||
" service { $resource_title:",
|
||||
" * => $attrs,",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" if $sysctl_apply and $files.has_key('/etc/sysctl.d/99-enroll.conf') {",
|
||||
" exec { 'enroll-apply-sysctl':",
|
||||
" command => $sysctl_ignore_apply_errors ? {",
|
||||
" true => \"/bin/sh -c 'sysctl -e -p /etc/sysctl.d/99-enroll.conf || true'\",",
|
||||
" default => 'sysctl -e -p /etc/sysctl.d/99-enroll.conf',",
|
||||
" },",
|
||||
" path => ['/sbin', '/usr/sbin', '/bin', '/usr/bin'],",
|
||||
" refreshonly => true,",
|
||||
" subscribe => File['/etc/sysctl.d/99-enroll.conf'],",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" # Generated notes are supplied through the $notes parameter for review.",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_site_pp(puppet_roles: List[PuppetRole], fqdn: Optional[str]) -> str:
|
||||
node_name = _pp_quote(fqdn) if fqdn else "default"
|
||||
if not puppet_roles:
|
||||
|
|
@ -497,6 +688,91 @@ def _render_site_pp(puppet_roles: List[PuppetRole], fqdn: Optional[str]) -> str:
|
|||
return f"node {node_name} {{\n{includes}\n}}\n"
|
||||
|
||||
|
||||
def _render_hiera_site_pp(node_names: List[str]) -> str:
|
||||
lines: List[str] = [
|
||||
"# Generated by Enroll from harvest state.",
|
||||
"# Per-node class lists and resources are read from Hiera data.",
|
||||
"",
|
||||
]
|
||||
for node_name in node_names:
|
||||
lines.extend(
|
||||
[
|
||||
f"node {_pp_quote(node_name)} {{",
|
||||
" $enroll_classes = lookup('enroll::classes', Array[String], 'unique', [])",
|
||||
" $enroll_classes.each |String $enroll_class| {",
|
||||
" include $enroll_class",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"node default {",
|
||||
" $enroll_classes = lookup('enroll::classes', Array[String], 'unique', [])",
|
||||
" $enroll_classes.each |String $enroll_class| {",
|
||||
" include $enroll_class",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _render_hiera_yaml() -> str:
|
||||
data = {
|
||||
"version": 5,
|
||||
"defaults": {"datadir": "data", "data_hash": "yaml_data"},
|
||||
"hierarchy": [
|
||||
{
|
||||
"name": "Enroll trusted certname node data",
|
||||
"path": "nodes/%{trusted.certname}.yaml",
|
||||
},
|
||||
{
|
||||
"name": "Enroll networking FQDN node data",
|
||||
"path": "nodes/%{facts.networking.fqdn}.yaml",
|
||||
},
|
||||
{"name": "Enroll common data", "path": "common.yaml"},
|
||||
],
|
||||
}
|
||||
return yaml.safe_dump(data, sort_keys=False, explicit_start=True)
|
||||
|
||||
|
||||
def _write_yaml(path: Path, data: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
yaml.safe_dump(data, sort_keys=True, explicit_start=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_hiera_node_data(
|
||||
out: Path, fqdn: str, puppet_roles: List[PuppetRole]
|
||||
) -> Path:
|
||||
node_data: Dict[str, Any] = {
|
||||
"enroll::classes": [r.module_name for r in puppet_roles]
|
||||
}
|
||||
for prole in puppet_roles:
|
||||
node_data.update(_role_hiera_values(prole))
|
||||
node_path = out / "data" / "nodes" / _node_data_filename(fqdn)
|
||||
_write_yaml(node_path, node_data)
|
||||
common_path = out / "data" / "common.yaml"
|
||||
if not common_path.exists():
|
||||
_write_yaml(common_path, {"enroll::classes": []})
|
||||
return node_path
|
||||
|
||||
|
||||
def _hiera_node_names(out: Path) -> List[str]:
|
||||
nodes_dir = out / "data" / "nodes"
|
||||
if not nodes_dir.is_dir():
|
||||
return []
|
||||
out_names: Set[str] = set()
|
||||
for path in nodes_dir.glob("*.yaml"):
|
||||
out_names.add(path.name[: -len(".yaml")])
|
||||
return sorted(out_names)
|
||||
|
||||
|
||||
def _write_metadata(module_dir: Path, module_name: str) -> None:
|
||||
(module_dir / "metadata.json").write_text(
|
||||
json.dumps(
|
||||
|
|
@ -517,9 +793,16 @@ def _write_metadata(module_dir: Path, module_name: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _render_readme(state: Dict[str, Any], puppet_roles: List[PuppetRole]) -> str:
|
||||
def _render_readme(
|
||||
state: Dict[str, Any],
|
||||
puppet_roles: List[PuppetRole],
|
||||
*,
|
||||
fqdn: Optional[str] = None,
|
||||
node_names: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
host = state.get("host", {}) if isinstance(state.get("host"), dict) else {}
|
||||
hostname = host.get("hostname") or "unknown"
|
||||
hiera_mode = bool(fqdn)
|
||||
role_lines = (
|
||||
"\n".join(
|
||||
f"- `{r.module_name}` from Enroll role `{r.role_name}`"
|
||||
|
|
@ -527,11 +810,39 @@ def _render_readme(state: Dict[str, Any], puppet_roles: List[PuppetRole]) -> str
|
|||
)
|
||||
or "- None."
|
||||
)
|
||||
node_lines = "\n".join(f"- `{n}`" for n in (node_names or [])) or "- None."
|
||||
notes: List[str] = []
|
||||
for r in puppet_roles:
|
||||
for note in r.notes:
|
||||
notes.append(f"`{r.module_name}`: {note}")
|
||||
notes_text = "\n".join(f"- {n}" for n in notes) or "- None."
|
||||
if hiera_mode:
|
||||
layout = f"""- `manifests/site.pp` declares node blocks and includes classes listed in Hiera key `enroll::classes`.
|
||||
- `hiera.yaml` configures per-node lookup from `data/nodes/%{{trusted.certname}}.yaml` with a fallback to `data/common.yaml`.
|
||||
- `data/nodes/{_node_data_filename(fqdn or '')}` contains this node's class list and class parameter data.
|
||||
- `modules/<role>/manifests/init.pp` contains reusable, data-driven classes.
|
||||
- `modules/<role>/files/nodes/<fqdn>/...` contains node-specific harvested file artifacts, avoiding clashes between hosts."""
|
||||
apply = f"""Run from this generated output directory, passing the node certname so Hiera selects the right node data:
|
||||
|
||||
```bash
|
||||
sudo puppet apply --modulepath ./modules --hiera_config ./hiera.yaml --certname {fqdn} manifests/site.pp --noop
|
||||
```
|
||||
|
||||
For Puppet agent/control-repo use, place this output where `hiera.yaml`, `data/`, `manifests/`, and `modules/` form the environment root. Re-running Enroll with another `--fqdn` into the same output directory adds or replaces that node's YAML without deleting existing node data."""
|
||||
else:
|
||||
layout = """- `manifests/site.pp` declares a `node` block and includes the generated classes in manifest order.
|
||||
- `modules/<role>/manifests/init.pp` contains resources for each generated Enroll role/snapshot or common package group.
|
||||
- `modules/<role>/files/` contains harvested file artifacts for that role or group.
|
||||
- Generated module names avoid Puppet reserved words such as `default`."""
|
||||
apply = """Run from this generated output directory so Puppet can find `./modules`, or pass an absolute module path:
|
||||
|
||||
```bash
|
||||
sudo puppet apply --modulepath ./modules manifests/site.pp --noop
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo puppet apply --modulepath /path/to/generated/modules /path/to/generated/manifests/site.pp --noop
|
||||
```"""
|
||||
return f"""# Enroll Puppet manifest
|
||||
|
||||
Generated by Enroll from harvest data for `{hostname}`.
|
||||
|
|
@ -540,10 +851,11 @@ This Puppet target reuses the existing harvest state without changing harvesting
|
|||
|
||||
## Layout
|
||||
|
||||
- `manifests/site.pp` declares a `node` block and includes the generated classes in manifest order.
|
||||
- `modules/<role>/manifests/init.pp` contains resources for each generated Enroll role/snapshot or common package group.
|
||||
- `modules/<role>/files/` contains harvested file artifacts for that role or group.
|
||||
- Generated module names avoid Puppet reserved words such as `default`.
|
||||
{layout}
|
||||
|
||||
## Known nodes
|
||||
|
||||
{node_lines if hiera_mode else '- Non-Hiera single-node output.'}
|
||||
|
||||
## Generated modules
|
||||
|
||||
|
|
@ -551,15 +863,7 @@ This Puppet target reuses the existing harvest state without changing harvesting
|
|||
|
||||
## Apply / check
|
||||
|
||||
Run from this generated output directory so Puppet can find `./modules`, or pass an absolute module path:
|
||||
|
||||
```bash
|
||||
sudo puppet apply --modulepath ./modules manifests/site.pp --noop
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo puppet apply --modulepath /path/to/generated/modules /path/to/generated/manifests/site.pp --noop
|
||||
```
|
||||
{apply}
|
||||
|
||||
## Generated resources
|
||||
|
||||
|
|
@ -607,7 +911,8 @@ class PuppetManifestRenderer:
|
|||
|
||||
state = PuppetRole.load_state(bundle_dir)
|
||||
out = Path(out_dir)
|
||||
if out.exists():
|
||||
hiera_mode = bool(fqdn)
|
||||
if out.exists() and not hiera_mode:
|
||||
shutil.rmtree(out)
|
||||
manifests_dir = out / "manifests"
|
||||
modules_dir = out / "modules"
|
||||
|
|
@ -628,15 +933,35 @@ class PuppetManifestRenderer:
|
|||
module_manifests.mkdir(parents=True, exist_ok=True)
|
||||
module_files.mkdir(parents=True, exist_ok=True)
|
||||
(module_manifests / "init.pp").write_text(
|
||||
_render_role_class(prole), encoding="utf-8"
|
||||
(
|
||||
_render_hiera_role_class(prole)
|
||||
if hiera_mode
|
||||
else _render_role_class(prole)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_metadata(module_dir, prole.module_name)
|
||||
|
||||
(manifests_dir / "site.pp").write_text(
|
||||
_render_site_pp(puppet_roles, fqdn), encoding="utf-8"
|
||||
)
|
||||
node_names: List[str] = []
|
||||
if hiera_mode and fqdn:
|
||||
(out / "hiera.yaml").write_text(_render_hiera_yaml(), encoding="utf-8")
|
||||
_write_hiera_node_data(out, fqdn, puppet_roles)
|
||||
node_names = _hiera_node_names(out)
|
||||
(manifests_dir / "site.pp").write_text(
|
||||
_render_hiera_site_pp(node_names), encoding="utf-8"
|
||||
)
|
||||
else:
|
||||
(manifests_dir / "site.pp").write_text(
|
||||
_render_site_pp(puppet_roles, fqdn), encoding="utf-8"
|
||||
)
|
||||
(out / "README.md").write_text(
|
||||
_render_readme(state, puppet_roles), encoding="utf-8"
|
||||
_render_readme(
|
||||
state,
|
||||
puppet_roles,
|
||||
fqdn=fqdn,
|
||||
node_names=node_names,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
313
enroll/system_paths.py
Normal file
313
enroll/system_paths.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Set, Tuple
|
||||
|
||||
|
||||
ALLOWED_UNOWNED_EXTS = {
|
||||
".cfg",
|
||||
".cnf",
|
||||
".conf",
|
||||
".ini",
|
||||
".json",
|
||||
".link",
|
||||
".mount",
|
||||
".netdev",
|
||||
".network",
|
||||
".path",
|
||||
".rules",
|
||||
".service",
|
||||
".socket",
|
||||
".target",
|
||||
".timer",
|
||||
".toml",
|
||||
".yaml",
|
||||
".yml",
|
||||
"", # allow extensionless (common in /etc/default and /etc/init.d)
|
||||
}
|
||||
|
||||
MAX_FILES_CAP = 4000
|
||||
MAX_UNOWNED_FILES_PER_ROLE = 500
|
||||
|
||||
|
||||
def is_confish(path: str) -> bool:
|
||||
base = os.path.basename(path)
|
||||
_, ext = os.path.splitext(base)
|
||||
return ext in ALLOWED_UNOWNED_EXTS
|
||||
|
||||
|
||||
def scan_unowned_under_roots(
|
||||
roots: List[str],
|
||||
owned_etc: Set[str],
|
||||
limit: int = MAX_UNOWNED_FILES_PER_ROLE,
|
||||
*,
|
||||
confish_only: bool = True,
|
||||
) -> List[str]:
|
||||
found: List[str] = []
|
||||
for root in roots:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
if len(found) >= limit:
|
||||
return found
|
||||
for fn in filenames:
|
||||
if len(found) >= limit:
|
||||
return found
|
||||
p = os.path.join(dirpath, fn)
|
||||
if not p.startswith("/etc/"):
|
||||
continue
|
||||
if p in owned_etc:
|
||||
continue
|
||||
if not os.path.isfile(p) or os.path.islink(p):
|
||||
continue
|
||||
if confish_only and not is_confish(p):
|
||||
continue
|
||||
found.append(p)
|
||||
return found
|
||||
|
||||
|
||||
def topdirs_for_package(pkg: str, pkg_to_etc_paths: Dict[str, List[str]]) -> Set[str]:
|
||||
topdirs: Set[str] = set()
|
||||
for path in pkg_to_etc_paths.get(pkg, []):
|
||||
parts = path.split("/", 3)
|
||||
if len(parts) >= 3 and parts[1] == "etc" and parts[2]:
|
||||
topdirs.add(parts[2])
|
||||
return topdirs
|
||||
|
||||
|
||||
_APT_SOURCE_GLOBS = [
|
||||
"/etc/apt/sources.list",
|
||||
"/etc/apt/sources.list.d/*.list",
|
||||
"/etc/apt/sources.list.d/*.sources",
|
||||
]
|
||||
|
||||
_SYSTEM_CAPTURE_GLOBS: List[Tuple[str, str]] = [
|
||||
("/etc/fstab", "system_mounts"),
|
||||
("/etc/crypttab", "system_mounts"),
|
||||
("/etc/sysctl.conf", "system_sysctl"),
|
||||
("/etc/sysctl.d/*", "system_sysctl"),
|
||||
("/etc/modprobe.d/*", "system_modprobe"),
|
||||
("/etc/modules", "system_modprobe"),
|
||||
("/etc/modules-load.d/*", "system_modprobe"),
|
||||
("/etc/netplan/*", "system_network"),
|
||||
("/etc/systemd/network/*", "system_network"),
|
||||
("/etc/network/interfaces", "system_network"),
|
||||
("/etc/network/interfaces.d/*", "system_network"),
|
||||
("/etc/resolvconf.conf", "system_network"),
|
||||
("/etc/resolvconf/resolv.conf.d/*", "system_network"),
|
||||
("/etc/NetworkManager/system-connections/*", "system_network"),
|
||||
("/etc/sysconfig/network*", "system_network"),
|
||||
("/etc/sysconfig/network-scripts/*", "system_network"),
|
||||
("/etc/nftables.conf", "system_firewall"),
|
||||
("/etc/nftables.d/*", "system_firewall"),
|
||||
("/etc/iptables/rules.v4", "system_firewall"),
|
||||
("/etc/iptables/rules.v6", "system_firewall"),
|
||||
("/etc/sysconfig/iptables", "system_firewall"),
|
||||
("/etc/sysconfig/ip6tables", "system_firewall"),
|
||||
("/etc/ipset.conf", "system_firewall"),
|
||||
("/etc/ipset/*", "system_firewall"),
|
||||
("/etc/ipset.d/*", "system_firewall"),
|
||||
("/etc/sysconfig/ipset", "system_firewall"),
|
||||
("/etc/default/ipset", "system_firewall"),
|
||||
("/etc/ufw/*", "system_firewall"),
|
||||
("/etc/default/ufw", "system_firewall"),
|
||||
("/etc/firewalld/*", "system_firewall"),
|
||||
("/etc/firewalld/zones/*", "system_firewall"),
|
||||
("/etc/selinux/config", "system_security"),
|
||||
("/etc/rc.local", "system_rc"),
|
||||
]
|
||||
|
||||
_PERSISTENT_IPTABLES_V4_GLOBS = [
|
||||
"/etc/iptables/rules.v4",
|
||||
"/etc/sysconfig/iptables",
|
||||
]
|
||||
|
||||
_PERSISTENT_IPTABLES_V6_GLOBS = [
|
||||
"/etc/iptables/rules.v6",
|
||||
"/etc/sysconfig/ip6tables",
|
||||
]
|
||||
|
||||
_PERSISTENT_IPSET_GLOBS = [
|
||||
"/etc/ipset.conf",
|
||||
"/etc/ipset/*",
|
||||
"/etc/ipset.d/*",
|
||||
"/etc/sysconfig/ipset",
|
||||
]
|
||||
|
||||
|
||||
def persistent_ipset_globs() -> List[str]:
|
||||
return list(_PERSISTENT_IPSET_GLOBS)
|
||||
|
||||
|
||||
def persistent_iptables_v4_globs() -> List[str]:
|
||||
return list(_PERSISTENT_IPTABLES_V4_GLOBS)
|
||||
|
||||
|
||||
def persistent_iptables_v6_globs() -> List[str]:
|
||||
return list(_PERSISTENT_IPTABLES_V6_GLOBS)
|
||||
|
||||
|
||||
def persistent_firewall_files(globs: List[str]) -> List[str]:
|
||||
"""Return persistent firewall files matching ``globs``."""
|
||||
|
||||
seen: Set[str] = set()
|
||||
out: List[str] = []
|
||||
for spec in globs:
|
||||
for path in iter_matching_files(spec):
|
||||
if path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
out.append(path)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def iter_matching_files(spec: str, *, cap: int = MAX_FILES_CAP) -> List[str]:
|
||||
"""Expand a glob spec and also walk directories to collect files."""
|
||||
|
||||
out: List[str] = []
|
||||
for p in glob.glob(spec):
|
||||
if len(out) >= cap:
|
||||
break
|
||||
if os.path.islink(p):
|
||||
continue
|
||||
if os.path.isfile(p):
|
||||
out.append(p)
|
||||
continue
|
||||
if os.path.isdir(p):
|
||||
for dirpath, _, filenames in os.walk(p):
|
||||
for fn in filenames:
|
||||
if len(out) >= cap:
|
||||
break
|
||||
fp = os.path.join(dirpath, fn)
|
||||
if os.path.islink(fp) or not os.path.isfile(fp):
|
||||
continue
|
||||
out.append(fp)
|
||||
if len(out) >= cap:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def parse_apt_signed_by(source_files: List[str]) -> Set[str]:
|
||||
"""Return absolute keyring paths referenced via signed-by / Signed-By."""
|
||||
|
||||
out: Set[str] = set()
|
||||
re_signed_by = re.compile(r"signed-by\s*=\s*([^\]\s]+)", re.IGNORECASE)
|
||||
re_signed_by_hdr = re.compile(r"^\s*Signed-By\s*:\s*(.+)$", re.IGNORECASE)
|
||||
|
||||
for sf in source_files:
|
||||
try:
|
||||
with open(sf, "r", encoding="utf-8", errors="replace") as f:
|
||||
for raw in f:
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
m = re_signed_by_hdr.match(line)
|
||||
if m:
|
||||
val = m.group(1).strip()
|
||||
if val.startswith("|"):
|
||||
continue
|
||||
toks = re.split(r"[\s,]+", val)
|
||||
for t in toks:
|
||||
if t.startswith("/"):
|
||||
out.add(t)
|
||||
continue
|
||||
|
||||
if "[" in line and "]" in line:
|
||||
bracket = line.split("[", 1)[1].split("]", 1)[0]
|
||||
for mm in re_signed_by.finditer(bracket):
|
||||
val = mm.group(1).strip().strip("\"'")
|
||||
for t in re.split(r"[\s,]+", val):
|
||||
if t.startswith("/"):
|
||||
out.add(t)
|
||||
continue
|
||||
|
||||
for mm in re_signed_by.finditer(line):
|
||||
val = mm.group(1).strip().strip("\"'")
|
||||
for t in re.split(r"[\s,]+", val):
|
||||
if t.startswith("/"):
|
||||
out.add(t)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def iter_apt_capture_paths() -> List[Tuple[str, str]]:
|
||||
"""Return (path, reason) pairs for APT configuration."""
|
||||
|
||||
reasons: Dict[str, str] = {}
|
||||
|
||||
if os.path.isdir("/etc/apt"):
|
||||
for dirpath, _, filenames in os.walk("/etc/apt"):
|
||||
for fn in filenames:
|
||||
p = os.path.join(dirpath, fn)
|
||||
if os.path.islink(p) or not os.path.isfile(p):
|
||||
continue
|
||||
reasons.setdefault(p, "apt_config")
|
||||
|
||||
apt_sources: List[str] = []
|
||||
for g in _APT_SOURCE_GLOBS:
|
||||
apt_sources.extend(iter_matching_files(g))
|
||||
for p in sorted(set(apt_sources)):
|
||||
reasons[p] = "apt_source"
|
||||
|
||||
for g in (
|
||||
"/etc/apt/trusted.gpg",
|
||||
"/etc/apt/trusted.gpg.d/*",
|
||||
"/etc/apt/keyrings/*",
|
||||
):
|
||||
for p in iter_matching_files(g):
|
||||
reasons[p] = "apt_keyring"
|
||||
|
||||
signed_by = parse_apt_signed_by(sorted(set(apt_sources)))
|
||||
for p in sorted(signed_by):
|
||||
if os.path.islink(p) or not os.path.isfile(p):
|
||||
continue
|
||||
if p.startswith("/etc/apt/"):
|
||||
reasons[p] = "apt_keyring"
|
||||
else:
|
||||
reasons[p] = "apt_signed_by_keyring"
|
||||
|
||||
return [(p, reasons[p]) for p in sorted(reasons.keys())]
|
||||
|
||||
|
||||
def iter_dnf_capture_paths() -> List[Tuple[str, str]]:
|
||||
"""Return (path, reason) pairs for DNF/YUM configuration on RPM systems."""
|
||||
|
||||
reasons: Dict[str, str] = {}
|
||||
|
||||
for root, tag in (
|
||||
("/etc/dnf", "dnf_config"),
|
||||
("/etc/yum", "yum_config"),
|
||||
):
|
||||
if os.path.isdir(root):
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
for fn in filenames:
|
||||
p = os.path.join(dirpath, fn)
|
||||
if os.path.islink(p) or not os.path.isfile(p):
|
||||
continue
|
||||
reasons.setdefault(p, tag)
|
||||
|
||||
for p in iter_matching_files("/etc/yum.conf"):
|
||||
reasons[p] = "yum_conf"
|
||||
for p in iter_matching_files("/etc/yum.repos.d/*.repo"):
|
||||
reasons[p] = "yum_repo"
|
||||
for p in iter_matching_files("/etc/pki/rpm-gpg/*"):
|
||||
reasons[p] = "rpm_gpg_key"
|
||||
|
||||
return [(p, reasons[p]) for p in sorted(reasons.keys())]
|
||||
|
||||
|
||||
def iter_system_capture_paths() -> List[Tuple[str, str]]:
|
||||
out: List[Tuple[str, str]] = []
|
||||
seen: Set[str] = set()
|
||||
for spec, reason in _SYSTEM_CAPTURE_GLOBS:
|
||||
for path in iter_matching_files(spec):
|
||||
if path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
out.append((path, reason))
|
||||
return sorted(out, key=lambda x: x[0])
|
||||
Reference in a new issue