feat(export): add csv, ansible and permission exports

This commit is contained in:
ahp
2026-09-12 13:47:31 +03:30
parent e999ba0e19
commit 0f8ed3fc3a
4 changed files with 208 additions and 0 deletions
View File
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import csv
import io
from collections.abc import Sequence
from pathlib import Path
from sshctl.config.resolver import ResolvedHost, resolve_all_hosts
def export_csv(
hosts: Sequence[ResolvedHost] | None = None,
*,
config_path: str | Path | None = None,
) -> str:
if hosts is None:
if config_path is None:
raise ValueError("Either hosts or config_path must be provided")
hosts = resolve_all_hosts(config_path)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["Host", "HostName", "User", "Port", "ProxyJump", "IdentityFile"])
for h in hosts:
writer.writerow([h.alias, h.hostname, h.user, h.port, h.proxy_jump, h.identity_file])
return output.getvalue()
def export_csv_to_file(
path: str | Path,
hosts: Sequence[ResolvedHost] | None = None,
*,
config_path: str | Path | None = None,
) -> Path:
path = Path(path)
content = export_csv(hosts, config_path=config_path)
path.write_text(content, encoding="utf-8")
return path
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from sshctl.config.parser import resolve_includes
from sshctl.core.models import SshConfig
def _build_ansible_groups(
config: SshConfig,
base_dir: Path,
) -> dict[str, Any]:
groups: dict[str, Any] = {}
conf_d = base_dir / "conf.d"
if not conf_d.exists():
return groups
_walk_conf_d(conf_d, groups, config)
return groups
def _walk_conf_d(
path: Path,
groups: dict[str, Any],
config: SshConfig,
) -> None:
children: list[Path] = sorted(
[p for p in path.iterdir() if p.is_dir()],
key=lambda p: p.name,
)
conf_files: list[Path] = sorted(
[p for p in path.glob("*.conf")],
key=lambda p: p.name,
)
group_name = _to_group_name(path.name)
group: dict[str, Any] = {"hosts": {}}
for conf_file in conf_files:
from sshctl.config.parser import parse_file
try:
parsed = parse_file(conf_file)
for block in parsed.blocks:
if block.alias == "*":
continue
host_vars: dict[str, str] = {}
hostname = block.get("HostName")
if hostname:
host_vars["ansible_host"] = hostname
user = block.get("User")
if user:
host_vars["ansible_user"] = user
port = block.get("Port")
if port:
host_vars["ansible_port"] = port
proxy = block.get("ProxyJump")
if proxy:
host_vars["ansible_ssh_common_args"] = f"-J {proxy}"
group["hosts"][block.alias] = host_vars
except Exception:
pass
groups[group_name] = group
for child in children:
_walk_conf_d(child, groups, config)
def _to_group_name(name: str) -> str:
name = name.replace("-", "_").replace(".", "_")
if name[0].isdigit():
name = f"z{name}"
return name
def generate_inventory(ssh_dir: str | Path) -> dict[str, Any]:
ssh_dir = Path(ssh_dir).expanduser().resolve()
config_path = ssh_dir / "config"
if not config_path.exists():
return {}
config = resolve_includes(config_path)
return _build_ansible_groups(config, ssh_dir)
def write_inventory(
ssh_dir: str | Path,
output_dir: str | Path | None = None,
) -> Path:
ssh_dir = Path(ssh_dir).expanduser().resolve()
inventory = generate_inventory(ssh_dir)
if output_dir is None:
output_dir = ssh_dir / "inventory.d"
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
out_path = output_dir / "hosts.yml"
yaml_str = yaml.dump(inventory, default_flow_style=False, sort_keys=True)
out_path.write_text(yaml_str, encoding="utf-8")
return out_path
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import stat
from pathlib import Path
from sshctl.core.exceptions import PermissionFixError
def fix_ssh_permissions(ssh_dir: str | Path) -> dict[str, int]:
ssh_dir = Path(ssh_dir).expanduser().resolve()
if not ssh_dir.exists():
raise PermissionFixError(f"SSH directory not found: {ssh_dir}")
counts: dict[str, int] = {"dirs": 0, "private": 0, "public": 0, "skipped": 0}
for entry in [ssh_dir] + list(ssh_dir.rglob("*")):
if entry.name.startswith(".git"):
continue
try:
if entry.is_dir():
entry.chmod(stat.S_IRWXU)
counts["dirs"] += 1
elif entry.suffix == ".pub" or entry.name.endswith("-cert.pub"):
entry.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
counts["public"] += 1
elif entry.name == ".gitkeep":
counts["skipped"] += 1
else:
entry.chmod(stat.S_IRUSR | stat.S_IWUSR)
counts["private"] += 1
except PermissionError:
counts["skipped"] += 1
return counts
def check_permissions(ssh_dir: str | Path) -> dict[str, list[str]]:
ssh_dir = Path(ssh_dir).expanduser().resolve()
issues: dict[str, list[str]] = {"bad_dir": [], "bad_private": [], "bad_public": []}
for entry in ssh_dir.rglob("*"):
if entry.name.startswith(".git"):
continue
try:
mode = entry.stat().st_mode
if entry.is_dir():
if mode & stat.S_IRWXU != stat.S_IRWXU:
issues["bad_dir"].append(str(entry))
elif entry.suffix == ".pub":
expected = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH
if mode & 0o777 != expected:
issues["bad_public"].append(str(entry))
elif entry.name not in (".gitkeep",):
expected = stat.S_IRUSR | stat.S_IWUSR
if mode & 0o777 != expected:
issues["bad_private"].append(str(entry))
except PermissionError:
pass
return issues