feat(export): add csv, ansible and permission exports
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user