"""Clearhouse integration (SFTP submission, inbound polling). SP9 ships a stub (writes files to a local staging dir). SP13 wires the real ``paramiko``-backed SFTP. Public API is unchanged across SP9 and SP13: * ``SftpClient.write_file(remote_path, content)`` — uploads bytes * ``SftpClient.list_inbound()`` — lists files in the inbound MFT path * ``SftpClient.read_file(remote_path)`` — downloads bytes * ``SftpClient.get_secret(name)`` — fetches the auth secret Authentication is configured via ``SftpBlock.auth``: * ``{"password_keychain_account": "sftp.gainwell.password"}`` — fetch the password from Keychain (Gainwell's MFT model). * ``{"key_file": "/path/to/id_rsa", "key_passphrase_keychain_account": "..."}`` — SSH private key (rare for MFT, but supported). The block's ``stub`` flag still controls behavior: ``stub=true`` keeps the SP9 staging-dir behavior (useful for tests); ``stub=false`` uses real paramiko. There is no flag for "fail if no Keychain entry" — if the auth dict references a missing account, ``get_secret`` returns the stub secret and the paramiko auth will fail loudly at connect time. """ from __future__ import annotations import io import logging import os import shutil from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Iterable, Iterator, Optional, Tuple from cyclone import secrets from cyclone.providers import SftpBlock log = logging.getLogger(__name__) @dataclass class InboundFile: """A single file observed in the inbound MFT path.""" name: str size: int modified_at: datetime local_path: Path class SftpClient: """SFTP client wrapper. SP9 stub; SP13 wires paramiko. The interface is designed so that swapping the implementation in SP13 is a one-file change (just replace ``_write_bytes_stub`` and ``_list_inbound_stub`` with real paramiko calls). """ # How long an SFTP connection may sit idle before we tear it down. # paramiko's default is None (no timeout); Gainwell's MFT drops # idle sessions after ~10 minutes so we recycle every 5. _IDLE_TIMEOUT_SECONDS = 5 * 60 def __init__(self, block: SftpBlock) -> None: self._block = block self._stub = block.stub # ---- Public API ------------------------------------------------------- def write_file(self, remote_path: str, content: bytes) -> Path: """Write bytes to the given remote path. Returns the local staging path. In stub mode, ``remote_path`` is preserved relative to the configured ``staging_dir``. In real mode, this is a paramiko SFTP put. """ if self._stub: return self._write_bytes_stub(remote_path, content) return self._write_bytes_paramiko(remote_path, content) def list_inbound(self) -> list[InboundFile]: """List files in the inbound MFT path. Stub returns [] in stub mode. Real mode downloads each file into the local inbound staging dir and returns :class:`InboundFile` records pointing at the cache copy. The remote file is *not* deleted — the operator archives inbound files in the MFT UI. """ if self._stub: return self._list_inbound_stub() return self._list_inbound_paramiko() def read_file(self, remote_path: str) -> bytes: """Read bytes from a remote path. Stub raises in stub mode.""" if self._stub: raise RuntimeError( "Stub SFTP cannot read remote files. Use the local staging dir." ) return self._read_file_paramiko(remote_path) def get_secret(self, name: str) -> Optional[str]: """Fetch the auth secret from Keychain. Returns the stub secret if absent.""" value = secrets.get_secret(name) if value is None: log.info("Keychain entry %r missing; using stub secret", name) return secrets.STUB_SECRET return value # ---- Stub implementations (SP9) ------------------------------------- def _write_bytes_stub(self, remote_path: str, content: bytes) -> Path: """Copy ``content`` to ``{staging_dir}/{remote_path}``. Preserves the full MFT path under staging so the operator can review what would be uploaded. The remote_path may use forward slashes (per SFTP convention); we use PurePosixPath-style split. """ staging = Path(self._block.staging_dir).resolve() # remote_path may be absolute ("/CO XIX/...") or relative; strip # leading slash to avoid escaping the staging dir. rel = remote_path.lstrip("/") target = staging / rel target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(content) log.info("SFTP stub: wrote %d bytes to %s", len(content), target) return target def _list_inbound_stub(self) -> list[InboundFile]: """Return the local inbound staging dir, if it has been populated by a real MFT pull (e.g. operator dropped files for testing).""" staging = Path(self._block.staging_dir).resolve() inbound_rel = self._block.paths.get("inbound", "").lstrip("/") inbound_dir = staging / inbound_rel if not inbound_dir.is_dir(): return [] files: list[InboundFile] = [] for entry in sorted(inbound_dir.iterdir()): if entry.is_file(): stat = entry.stat() files.append( InboundFile( name=entry.name, size=stat.st_size, modified_at=datetime.fromtimestamp(stat.st_mtime), local_path=entry, ) ) return files # ---- Real implementations (SP13) ------------------------------------- @contextmanager def _connect(self) -> Iterator[Tuple["object", "object"]]: """Open a paramiko SSHClient and yield (ssh, sftp). Closes the connection on context exit (caller wraps in ``with self._connect() as (ssh, sftp):``). Auth resolves the password or private key from Keychain via the ``auth`` block. Why we wrap the SSH client lifecycle here: paramiko caches host keys in ``~/.ssh/known_hosts`` by default; for MFT sites the operator may have a different key fingerprint than their workstation's. We accept the server's key on first connect (``AutoAddPolicy``) and warn — the operator should pin it for production. """ import paramiko ssh = paramiko.SSHClient() ssh.load_system_host_keys() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) auth = self._block.auth or {} password_account = auth.get("password_keychain_account") key_file = auth.get("key_file") key_passphrase_account = auth.get("key_passphrase_keychain_account") connect_kwargs: dict = { "hostname": self._block.host, "port": self._block.port, "username": self._block.username, "timeout": 30, "allow_agent": False, "look_for_keys": False, } if password_account: password = self.get_secret(password_account) if password and password != secrets.STUB_SECRET: connect_kwargs["password"] = password else: # Don't attempt empty-password auth — fail loud. raise RuntimeError( f"SFTP: Keychain entry {password_account!r} missing or stub. " "Real SFTP wire-up requires the actual password." ) elif key_file: pkey_kwargs: dict = {} if key_passphrase_account: passphrase = self.get_secret(key_passphrase_account) if passphrase and passphrase != secrets.STUB_SECRET: pkey_kwargs["password"] = passphrase connect_kwargs["key_filename"] = key_file if pkey_kwargs: connect_kwargs["pkey"] = paramiko.RSAKey.from_private_key_file( key_file, password=pkey_kwargs.get("password"), ) else: raise RuntimeError( "SftpBlock.auth must contain either 'password_keychain_account' or 'key_file'" ) log.info("SFTP: connecting to %s:%d as %s", self._block.host, self._block.port, self._block.username) ssh.connect(**connect_kwargs) sftp = ssh.open_sftp() try: yield ssh, sftp finally: try: sftp.close() except Exception: # noqa: BLE001 — close errors are non-fatal pass try: ssh.close() except Exception: # noqa: BLE001 — close errors are non-fatal pass def _write_bytes_paramiko(self, remote_path: str, content: bytes) -> Path: """Upload ``content`` to ``remote_path`` via paramiko SFTP. Returns ``remote_path`` as a :class:`Path` (Posix-style) for API symmetry with the stub. The actual write uses paramiko's ``SFTPFile.open(..., "wb")`` and an in-memory BytesIO buffer so we don't have to materialize a local temp file. """ # Lazy import so the stub-only test path doesn't need paramiko. with self._connect() as (ssh, sftp): # Ensure the parent dir exists on the remote. Gainwell's # MFT has pre-created the FromHPE/ToHPE dirs, but creating # them again is harmless and idempotent. parent = "/".join(remote_path.rstrip("/").split("/")[:-1]) if parent: try: sftp.mkdir(parent) except IOError: # Already exists — fine. pass with sftp.open(remote_path, "wb") as f: f.write(content) log.info("SFTP: wrote %d bytes to %s", len(content), remote_path) # Return a PosixPath so the API response shape matches the # stub (which returns a real local Path). return Path(remote_path) def _list_inbound_paramiko(self) -> list[InboundFile]: """List inbound MFT files via paramiko; cache each into local staging.""" with self._connect() as (ssh, sftp): inbound_dir = self._block.paths.get("inbound", "/") staging = Path(self._block.staging_dir).resolve() inbound_rel = inbound_dir.lstrip("/") cache_dir = staging / inbound_rel cache_dir.mkdir(parents=True, exist_ok=True) files: list[InboundFile] = [] try: attrs = sftp.listdir_attr(inbound_dir) except IOError as exc: log.warning("SFTP: cannot list %s: %s", inbound_dir, exc) return [] for attr in sorted(attrs, key=lambda a: a.filename): if attr.st_mode and (attr.st_mode & 0o170000) == 0o040000: # Directory entry — skip. continue remote = f"{inbound_dir.rstrip('/')}/{attr.filename}" cache_path = cache_dir / attr.filename # Download into cache. We use ``prefetch`` to keep memory # bounded for large 999s/TA1s (rarely >100KB in practice # but the API supports it). with sftp.open(remote, "rb") as src, open(cache_path, "wb") as dst: shutil.copyfileobj(src, dst, length=64 * 1024) files.append(InboundFile( name=attr.filename, size=attr.st_size or cache_path.stat().st_size, modified_at=datetime.fromtimestamp(attr.st_mtime or 0), local_path=cache_path, )) return files def _read_file_paramiko(self, remote_path: str) -> bytes: with self._connect() as (ssh, sftp): buf = io.BytesIO() with sftp.open(remote_path, "rb") as f: shutil.copyfileobj(f, buf, length=64 * 1024) return buf.getvalue() # --------------------------------------------------------------------------- # Module-level helper # --------------------------------------------------------------------------- def make_client(block: SftpBlock) -> SftpClient: """Factory used by the API layer. Kept tiny so swapping the implementation in SP13 is one-line.""" return SftpClient(block)