biiiiig refactor to support jinjaturtle and multi site mode
This commit is contained in:
parent
576649a49c
commit
f255ba566c
11 changed files with 1331 additions and 298 deletions
|
|
@ -1,10 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from .harvest import harvest
|
||||
from .manifest import manifest
|
||||
|
||||
|
||||
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--fqdn",
|
||||
help="Host FQDN/name for site-mode output (creates inventory/, inventory/host_vars/, playbooks/).",
|
||||
)
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument(
|
||||
"--jinjaturtle",
|
||||
action="store_true",
|
||||
help="Attempt jinjaturtle template integration (it will error if jinjaturtle is not found on PATH).",
|
||||
)
|
||||
g.add_argument(
|
||||
"--no-jinjaturtle",
|
||||
action="store_true",
|
||||
help="Do not use jinjaturtle integration, even if it is installed.",
|
||||
)
|
||||
|
||||
|
||||
def _jt_mode(args: argparse.Namespace) -> str:
|
||||
if getattr(args, "jinjaturtle", False):
|
||||
return "on"
|
||||
if getattr(args, "no_jinjaturtle", False):
|
||||
return "off"
|
||||
return "auto"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(prog="enroll")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
|
@ -23,9 +50,10 @@ def main() -> None:
|
|||
required=True,
|
||||
help="Output directory for generated roles/playbook Ansible manifest",
|
||||
)
|
||||
_add_common_manifest_args(r)
|
||||
|
||||
e = sub.add_parser(
|
||||
"enroll", help="Harvest state, then manifest Ansible code, in one shot"
|
||||
"single-shot", help="Harvest state, then manifest Ansible code, in one shot"
|
||||
)
|
||||
e.add_argument(
|
||||
"--harvest", required=True, help="Path to the directory to place the harvest in"
|
||||
|
|
@ -35,6 +63,7 @@ def main() -> None:
|
|||
required=True,
|
||||
help="Output directory for generated roles/playbook Ansible manifest",
|
||||
)
|
||||
_add_common_manifest_args(e)
|
||||
|
||||
args = ap.parse_args()
|
||||
|
||||
|
|
@ -42,7 +71,7 @@ def main() -> None:
|
|||
path = harvest(args.out)
|
||||
print(path)
|
||||
elif args.cmd == "manifest":
|
||||
manifest(args.harvest, args.out)
|
||||
elif args.cmd == "enroll":
|
||||
manifest(args.harvest, args.out, fqdn=args.fqdn, jinjaturtle=_jt_mode(args))
|
||||
elif args.cmd == "single-shot":
|
||||
harvest(args.harvest)
|
||||
manifest(args.harvest, args.out)
|
||||
manifest(args.harvest, args.out, fqdn=args.fqdn, jinjaturtle=_jt_mode(args))
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ SENSITIVE_CONTENT_PATTERNS = [
|
|||
re.compile(rb"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"),
|
||||
]
|
||||
|
||||
COMMENT_PREFIXES = (b"#", b";", b"//")
|
||||
BLOCK_START = b"/*"
|
||||
BLOCK_END = b"*/"
|
||||
|
||||
|
||||
@dataclass
|
||||
class IgnorePolicy:
|
||||
|
|
@ -42,6 +46,28 @@ class IgnorePolicy:
|
|||
if self.deny_globs is None:
|
||||
self.deny_globs = list(DEFAULT_DENY_GLOBS)
|
||||
|
||||
def iter_effective_lines(self, content: bytes):
|
||||
in_block = False
|
||||
for raw in content.splitlines():
|
||||
line = raw.lstrip()
|
||||
|
||||
if in_block:
|
||||
if BLOCK_END in line:
|
||||
in_block = False
|
||||
continue
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith(BLOCK_START):
|
||||
in_block = True
|
||||
continue
|
||||
|
||||
if line.startswith(COMMENT_PREFIXES) or line.startswith(b"*"):
|
||||
continue
|
||||
|
||||
yield raw
|
||||
|
||||
def deny_reason(self, path: str) -> Optional[str]:
|
||||
for g in self.deny_globs:
|
||||
if fnmatch.fnmatch(path, g):
|
||||
|
|
@ -67,8 +93,9 @@ class IgnorePolicy:
|
|||
if b"\x00" in data:
|
||||
return "binary_like"
|
||||
|
||||
for pat in SENSITIVE_CONTENT_PATTERNS:
|
||||
if pat.search(data):
|
||||
return "sensitive_content"
|
||||
for line in self.iter_effective_lines(data):
|
||||
for pat in SENSITIVE_CONTENT_PATTERNS:
|
||||
if pat.search(line):
|
||||
return "sensitive_content"
|
||||
|
||||
return None
|
||||
|
|
|
|||
105
enroll/jinjaturtle.py
Normal file
105
enroll/jinjaturtle.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
SUPPORTED_EXTS = {".ini", ".json", ".toml", ".yaml", ".yml", ".xml"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JinjifyResult:
|
||||
template_text: str
|
||||
vars_text: str # YAML mapping text (no leading --- expected)
|
||||
|
||||
|
||||
def find_jinjaturtle_cmd() -> Optional[str]:
|
||||
"""Return the executable path for jinjaturtle if found on PATH."""
|
||||
return shutil.which("jinjaturtle")
|
||||
|
||||
|
||||
def can_jinjify_path(path: str) -> bool:
|
||||
p = Path(path)
|
||||
return p.suffix.lower() in SUPPORTED_EXTS
|
||||
|
||||
|
||||
def run_jinjaturtle(
|
||||
jt_exe: str,
|
||||
src_path: str,
|
||||
*,
|
||||
role_name: str,
|
||||
force_format: Optional[str] = None,
|
||||
) -> JinjifyResult:
|
||||
"""
|
||||
Run jinjaturtle against src_path and return (template, defaults-yaml).
|
||||
Uses tempfiles and captures outputs.
|
||||
|
||||
jinjaturtle CLI:
|
||||
jinjaturtle <config> -r <role> [-f <format>] [-d <defaults-output>] [-t <template-output>]
|
||||
"""
|
||||
src = Path(src_path)
|
||||
if not src.is_file():
|
||||
raise FileNotFoundError(src_path)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="enroll-jt-") as td:
|
||||
td_path = Path(td)
|
||||
defaults_out = td_path / "defaults.yml"
|
||||
template_out = td_path / "template.j2"
|
||||
|
||||
cmd = [
|
||||
jt_exe,
|
||||
str(src),
|
||||
"-r",
|
||||
role_name,
|
||||
"-d",
|
||||
str(defaults_out),
|
||||
"-t",
|
||||
str(template_out),
|
||||
]
|
||||
if force_format:
|
||||
cmd.extend(["-f", force_format])
|
||||
|
||||
p = subprocess.run(cmd, text=True, capture_output=True) # nosec
|
||||
if p.returncode != 0:
|
||||
raise RuntimeError(
|
||||
"jinjaturtle failed for %s (role=%s)\ncmd=%r\nstdout=%s\nstderr=%s"
|
||||
% (src_path, role_name, cmd, p.stdout, p.stderr)
|
||||
)
|
||||
|
||||
vars_text = defaults_out.read_text(encoding="utf-8").strip()
|
||||
template_text = template_out.read_text(encoding="utf-8")
|
||||
|
||||
# jinjaturtle outputs a YAML mapping; strip leading document marker if present
|
||||
if vars_text.startswith("---"):
|
||||
vars_text = "\n".join(vars_text.splitlines()[1:]).lstrip()
|
||||
|
||||
return JinjifyResult(
|
||||
template_text=template_text, vars_text=vars_text.rstrip() + "\n"
|
||||
)
|
||||
|
||||
|
||||
def replace_or_append_block(
|
||||
base_text: str,
|
||||
*,
|
||||
begin: str,
|
||||
end: str,
|
||||
block_body: str,
|
||||
) -> str:
|
||||
"""Replace a marked block if present; else append it."""
|
||||
pattern = re.compile(
|
||||
re.escape(begin) + r".*?" + re.escape(end),
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
new_block = f"{begin}\n{block_body.rstrip()}\n{end}"
|
||||
if pattern.search(base_text):
|
||||
return pattern.sub(new_block, base_text).rstrip() + "\n"
|
||||
# ensure base ends with newline
|
||||
bt = base_text.rstrip() + "\n"
|
||||
if not bt.endswith("\n"):
|
||||
bt += "\n"
|
||||
return bt + "\n" + new_block + "\n"
|
||||
1140
enroll/manifest.py
1140
enroll/manifest.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue