feat(core): add sshctl settings and host models
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sshctl.core._version import __version__
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.1"
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class SshctlConfig(BaseSettings):
|
||||
model_config = {"env_prefix": "SSHCTL_"}
|
||||
|
||||
ssh_dir: Path = Field(default=Path("~/.ssh").expanduser())
|
||||
config_file: str = "config"
|
||||
verbose: bool = False
|
||||
timeout: int = 10
|
||||
|
||||
def ssh_config_path(self) -> Path:
|
||||
return self.ssh_dir / self.config_file
|
||||
|
||||
def conf_d_path(self) -> Path:
|
||||
return self.ssh_dir / "conf.d"
|
||||
|
||||
def keys_d_path(self) -> Path:
|
||||
return self.ssh_dir / "keys.d"
|
||||
|
||||
def control_d_path(self) -> Path:
|
||||
return self.ssh_dir / "control.d"
|
||||
|
||||
def scripts_path(self) -> Path:
|
||||
return self.ssh_dir / "scripts"
|
||||
|
||||
def git_path(self) -> Path:
|
||||
return self.ssh_dir / ".git"
|
||||
|
||||
|
||||
def load_config(path: Path | None = None) -> SshctlConfig:
|
||||
if path and path.exists():
|
||||
import yaml
|
||||
|
||||
raw = yaml.safe_load(path.read_text()) or {}
|
||||
return SshctlConfig(**raw)
|
||||
return SshctlConfig()
|
||||
@@ -0,0 +1,30 @@
|
||||
class SshctlError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigError(SshctlError):
|
||||
pass
|
||||
|
||||
|
||||
class ParseError(SshctlError):
|
||||
pass
|
||||
|
||||
|
||||
class ResolverError(SshctlError):
|
||||
pass
|
||||
|
||||
|
||||
class CheckError(SshctlError):
|
||||
pass
|
||||
|
||||
|
||||
class ExecError(SshctlError):
|
||||
pass
|
||||
|
||||
|
||||
class PermissionFixError(SshctlError):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(SshctlError):
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
_LOG: BoundLogger | None = None
|
||||
|
||||
|
||||
def setup_logging(*, verbose: bool = False) -> None:
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.dev.ConsoleRenderer() if verbose else structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
context_class=dict,
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> BoundLogger:
|
||||
global _LOG
|
||||
if _LOG is None:
|
||||
_LOG = structlog.get_logger(name)
|
||||
return _LOG
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostOption:
|
||||
name: str
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostBlock:
|
||||
alias: str
|
||||
options: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def get(self, key: str, default: str | None = None) -> str | None:
|
||||
return self.options.get(key, default)
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
self.options[key] = value
|
||||
|
||||
@property
|
||||
def hostname(self) -> str | None:
|
||||
return self.get("HostName")
|
||||
|
||||
@property
|
||||
def user(self) -> str | None:
|
||||
return self.get("User")
|
||||
|
||||
@property
|
||||
def port(self) -> int | None:
|
||||
val = self.get("Port")
|
||||
return int(val) if val else None
|
||||
|
||||
@property
|
||||
def proxy_jump(self) -> str | None:
|
||||
return self.get("ProxyJump")
|
||||
|
||||
@property
|
||||
def identity_file(self) -> str | None:
|
||||
return self.get("IdentityFile")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SshConfig:
|
||||
blocks: list[HostBlock] = field(default_factory=list)
|
||||
raw_includes: list[str] = field(default_factory=list)
|
||||
|
||||
def find(self, alias: str) -> HostBlock | None:
|
||||
for block in self.blocks:
|
||||
if block.alias == alias:
|
||||
return block
|
||||
return None
|
||||
|
||||
def find_all(self, pattern: str) -> list[HostBlock]:
|
||||
from fnmatch import fnmatch
|
||||
|
||||
return [b for b in self.blocks if fnmatch(b.alias, pattern)]
|
||||
|
||||
@property
|
||||
def aliases(self) -> list[str]:
|
||||
return [b.alias for b in self.blocks if not _is_pattern(b.alias)]
|
||||
|
||||
|
||||
def _is_pattern(alias: str) -> bool:
|
||||
return any(c in alias for c in "*?[]")
|
||||
|
||||
|
||||
Universe = Literal["internal", "external"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZonePath:
|
||||
universe: Universe
|
||||
sector: str | None = None
|
||||
cluster: str | None = None
|
||||
zone: str | None = None
|
||||
vlan: str | None = None
|
||||
|
||||
def to_rel_path(self) -> Path:
|
||||
parts: list[str] = ["conf.d", self.universe]
|
||||
if self.sector:
|
||||
parts.append(self.sector)
|
||||
if self.cluster:
|
||||
parts.append(self.cluster)
|
||||
if self.zone:
|
||||
parts.append(self.zone)
|
||||
if self.vlan:
|
||||
parts.append(self.vlan)
|
||||
return Path(*parts)
|
||||
|
||||
@classmethod
|
||||
def from_rel_path(cls, path: Path) -> ZonePath:
|
||||
parts = path.parts
|
||||
if len(parts) < 2 or parts[0] != "conf.d":
|
||||
raise ValueError(f"Invalid zone path: {path}")
|
||||
raw_universe = parts[1]
|
||||
if raw_universe not in ("internal", "external"):
|
||||
raise ValueError(f"Unknown universe: {raw_universe}")
|
||||
universe = cast(Literal["internal", "external"], raw_universe)
|
||||
sector = parts[2] if len(parts) > 2 else None
|
||||
cluster = parts[3] if len(parts) > 3 else None
|
||||
zone = parts[4] if len(parts) > 4 else None
|
||||
vlan = parts[5] if len(parts) > 5 else None
|
||||
return cls(universe=universe, sector=sector, cluster=cluster, zone=zone, vlan=vlan)
|
||||
Reference in New Issue
Block a user