fixes and improvements
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
- Current app is `backend/` (FastAPI) plus `frontend/` (Vite React); ignore Streamlit-era commands in `CONTRIBUTING.md`.
|
||||||
|
- Backend entrypoint: `backend/src/media_library_viewer_api/main.py` (`media_library_viewer_api.main:app`).
|
||||||
|
- Frontend entrypoint: `frontend/src/main.tsx`.
|
||||||
|
- Backend uses a `src/` layout; tests live in `backend/tests/`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- Backend setup: `cd backend && python -m venv .venv && source .venv/bin/activate && pip install -e '.[dev]'`
|
||||||
|
- Backend run: `uvicorn media_library_viewer_api.main:app --reload --port 8000`; if not installed, use `PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000`.
|
||||||
|
- Backend tests: run `pytest` from `backend/`; focused checks can use `pytest tests/test_api.py` or `pytest -k <expr>`; if the package is not installed, use `PYTHONPATH=src pytest`.
|
||||||
|
- Frontend setup: `cd frontend && npm install`
|
||||||
|
- Frontend dev/build/lint: `npm run dev`, `npm run build`, `npm run lint`; `npm run build` already typechecks via `tsc -b`.
|
||||||
|
- Focused frontend typecheck: `npx tsc --noEmit`
|
||||||
|
- Local dev stack: `docker compose -f docker-compose.dev.yml up --build`
|
||||||
|
- Production stack: `docker compose up --build`
|
||||||
|
|
||||||
|
## Repo-Specific Gotchas
|
||||||
|
- Root compose files rely on environment-variable interpolation, not `env_file`; export required values before running them.
|
||||||
|
- Production compose needs the host/cert and OIDC variables from `docker-compose.yml` (`BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, `CERT_RESOLVER`, and the frontend OIDC vars).
|
||||||
|
- Dev compose runs with auth off and does not need SSH key material unless you add remote SSH machines.
|
||||||
|
- `backend_cache` persists the media index and the managed `known_hosts` file.
|
||||||
|
- SSH host-key checking is strict, but the first successful connect records the host key into backend-managed `known_hosts`.
|
||||||
|
- Backend startup validates auth settings, rebuilds managed `known_hosts`, and starts the mail queue and monitoring poller.
|
||||||
|
- Machine-level settings now own Jellyfin/Jellyseerr/SSH config; the backend seeds a local machine automatically.
|
||||||
|
- Remote job templates live in `backend/src/media_library_viewer_api/jobs.py`; keep shell quoting intact.
|
||||||
|
- Backend Ruff config is in `backend/pyproject.toml` and uses line length 120 with Python 3.11.
|
||||||
|
- Update `docs/REQUIREMENTS.md` whenever behavior, UX, or architecture changes.
|
||||||
|
- Do not commit `.env`, `.streamlit/secrets.toml`, private keys, or tokens.
|
||||||
@@ -20,8 +20,6 @@ from typing import Any
|
|||||||
|
|
||||||
import paramiko
|
import paramiko
|
||||||
|
|
||||||
from media_library_viewer_api.services.known_hosts import ensure_known_host
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,26 +64,12 @@ class RemoteSSHClient:
|
|||||||
def connect(self) -> paramiko.SSHClient:
|
def connect(self) -> paramiko.SSHClient:
|
||||||
"""Create or reuse the Paramiko connection.
|
"""Create or reuse the Paramiko connection.
|
||||||
|
|
||||||
Unknown host keys are recorded on first contact in the managed
|
Host keys are expected to be managed ahead of time by startup synthesis
|
||||||
known_hosts file when one is configured. After that, strict checking
|
or explicit validation flows. Runtime connections only load the managed
|
||||||
remains in effect so host key changes are still rejected.
|
known_hosts file and then let Paramiko enforce strict checking.
|
||||||
"""
|
"""
|
||||||
if self._client:
|
if self._client:
|
||||||
return self._client
|
return self._client
|
||||||
if self.known_hosts_path:
|
|
||||||
try:
|
|
||||||
ensure_known_host(self.host, self.port, Path(self.known_hosts_path), strict=True)
|
|
||||||
except Exception as exc:
|
|
||||||
message = str(exc).lower()
|
|
||||||
if "protocol banner" in message:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"SSH banner not received from {self.host}:{self.port}. "
|
|
||||||
"The host key could not be recorded because the backend could not talk to SSH."
|
|
||||||
) from exc
|
|
||||||
raise RuntimeError(
|
|
||||||
f"SSH host key lookup failed for {self.host}:{self.port}. "
|
|
||||||
"Confirm the host and port are correct and that SSH is reachable."
|
|
||||||
) from exc
|
|
||||||
client = paramiko.SSHClient()
|
client = paramiko.SSHClient()
|
||||||
client.load_system_host_keys()
|
client.load_system_host_keys()
|
||||||
if self.known_hosts_path and Path(self.known_hosts_path).is_file():
|
if self.known_hosts_path and Path(self.known_hosts_path).is_file():
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_uses_existing_known_hosts_without_reprobing(tmp_path):
|
||||||
|
known_hosts_path = tmp_path / "known_hosts"
|
||||||
|
known_hosts_path.write_text("example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAAFAKE\n")
|
||||||
|
|
||||||
|
ssh_client = MagicMock()
|
||||||
|
ssh_client.connect.return_value = None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("media_library_viewer_api.clients.ssh.paramiko.SSHClient", return_value=ssh_client),
|
||||||
|
patch("media_library_viewer_api.clients.ssh.paramiko.RejectPolicy", return_value=object()),
|
||||||
|
):
|
||||||
|
client = RemoteSSHClient(
|
||||||
|
host="example.com",
|
||||||
|
username="alex",
|
||||||
|
known_hosts_path=str(known_hosts_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
client.connect()
|
||||||
|
|
||||||
|
ssh_client.load_system_host_keys.assert_called_once_with()
|
||||||
|
ssh_client.load_host_keys.assert_called_once_with(str(known_hosts_path))
|
||||||
|
ssh_client.set_missing_host_key_policy.assert_called_once()
|
||||||
|
ssh_client.connect.assert_called_once()
|
||||||
Reference in New Issue
Block a user