diff --git a/harness/tests/test_doctor_cli.py b/harness/tests/test_doctor_cli.py new file mode 100644 index 00000000..1b40418c --- /dev/null +++ b/harness/tests/test_doctor_cli.py @@ -0,0 +1,96 @@ +import json +from pathlib import Path + +from typer.testing import CliRunner + +from tht.cli import app + + +runner = CliRunner() + + +def _config(path: Path, *, absolute_sessions: Path | None = None) -> Path: + sessions = absolute_sessions or Path("sessions") + path.write_text( + "dwh:\n" + " type: postgres_direct\n" + " connection:\n" + " database: patient_db\n" + " schema: private_schema\n" + " user: pii_user\n" + " password: super-secret\n" + "roots:\n" + " artifacts: artifacts\n" + " indexes: indexes\n" + f" sessions: {sessions}\n" + ) + return path + + +def test_doctor_json_reports_portable_path_statuses_without_secrets(monkeypatch, tmp_path): + cfg = _config(tmp_path / "demo.yaml") + monkeypatch.setenv("THT_DATA_ROOT", str(tmp_path / "data")) + + result = runner.invoke(app, ["doctor", "--json", "--config", str(cfg)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload == { + "ok": True, + "components": { + "config": {"status": "ok"}, + "data_root": {"status": "ok"}, + "workspace_paths": {"status": "ok", "legacy_absolute": []}, + }, + } + assert "super-secret" not in result.stdout + assert "patient_db" not in result.stdout + assert "pii_user" not in result.stdout + assert result.stderr == "" + + +def test_doctor_json_flags_absolute_legacy_paths(monkeypatch, tmp_path): + cfg = _config(tmp_path / "demo.yaml", absolute_sessions=tmp_path / "old-sessions") + monkeypatch.setenv("THT_DATA_ROOT", str(tmp_path / "data")) + + result = runner.invoke(app, ["doctor", "--json", "--config", str(cfg)]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["components"]["workspace_paths"] == { + "status": "warning", + "legacy_absolute": ["sessions"], + } + assert str(tmp_path) not in result.stdout + + +def test_doctor_json_returns_structured_config_error(monkeypatch, tmp_path): + cfg = _config(tmp_path / "demo.yaml") + monkeypatch.setenv("THT_DATA_ROOT", str(tmp_path / "data")) + cfg.write_text(cfg.read_text().replace("sessions: sessions", "sessions: ../../private")) + + result = runner.invoke(app, ["doctor", "--json", "--config", str(cfg)]) + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["ok"] is False + assert payload["components"]["workspace_paths"]["status"] == "error" + assert "outside workspace root" in payload["components"]["workspace_paths"]["message"] + assert result.stderr == "" + + +def test_doctor_json_does_not_echo_invalid_config_values(monkeypatch, tmp_path): + cfg = _config(tmp_path / "demo.yaml") + monkeypatch.setenv("THT_DATA_ROOT", str(tmp_path / "data")) + cfg.write_text(cfg.read_text().replace("database: patient_db", "")) + + result = runner.invoke(app, ["doctor", "--json", "--config", str(cfg)]) + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["components"]["config"] == { + "status": "error", + "message": "configuration is invalid", + } + assert "super-secret" not in result.stdout + assert "pii_user" not in result.stdout diff --git a/harness/tests/test_portable_paths.py b/harness/tests/test_portable_paths.py new file mode 100644 index 00000000..b24f2b78 --- /dev/null +++ b/harness/tests/test_portable_paths.py @@ -0,0 +1,77 @@ +from pathlib import Path + +import pytest + +from tht.config import ConfigError, load_config +from tht.paths import resolve_workspace_paths + + +def _write_config(path: Path, *, sessions: str = "sessions", absolute: Path | None = None) -> Path: + root = absolute or Path("artifacts") + path.write_text( + "dwh:\n" + " type: postgres_direct\n" + " connection:\n" + " database: db\n" + " schema: public\n" + " user: user\n" + " password: secret\n" + "roots:\n" + f" artifacts: {root}\n" + f" indexes: {root if absolute else 'indexes'}\n" + f" sessions: {absolute if absolute else sessions}\n" + ) + return path + + +def test_relative_paths_resolve_under_workspace_root(tmp_path): + cfg_path = _write_config(tmp_path / "demo.yaml") + cfg = load_config(cfg_path) + + resolved = resolve_workspace_paths(cfg_path, cfg, tmp_path / "data") + + assert resolved.workspace == tmp_path / "data/workspaces/demo" + assert resolved.sessions == tmp_path / "data/workspaces/demo/sessions" + assert resolved.artifacts == tmp_path / "data/workspaces/demo/artifacts" + assert resolved.indexes == tmp_path / "data/workspaces/demo/indexes" + assert resolved.corpus == tmp_path / "data/workspaces/demo/corpus" + + +def test_absolute_legacy_paths_are_preserved(tmp_path): + legacy = tmp_path / "existing-workspace" + cfg_path = _write_config(tmp_path / "demo.yaml", absolute=legacy) + cfg = load_config(cfg_path) + + resolved = resolve_workspace_paths(cfg_path, cfg, tmp_path / "data") + + assert resolved.sessions == legacy + assert resolved.artifacts == legacy + assert resolved.indexes == legacy + + +def test_path_escape_is_rejected(tmp_path): + cfg_path = _write_config(tmp_path / "demo.yaml", sessions="../../private") + cfg = load_config(cfg_path) + + with pytest.raises(ConfigError, match="outside workspace root"): + resolve_workspace_paths(cfg_path, cfg, tmp_path / "data") + + +def test_data_root_environment_activates_portable_paths(monkeypatch, tmp_path): + cfg_path = _write_config(tmp_path / "demo.yaml") + monkeypatch.setenv("THT_DATA_ROOT", str(tmp_path / "data")) + + cfg = load_config(cfg_path) + + assert cfg.paths.sessions == tmp_path / "data/workspaces/demo/sessions" + assert cfg.paths.artifacts == tmp_path / "data/workspaces/demo/artifacts" + + +def test_no_data_root_preserves_legacy_relative_paths(monkeypatch, tmp_path): + cfg_path = _write_config(tmp_path / "demo.yaml") + monkeypatch.delenv("THT_DATA_ROOT", raising=False) + + cfg = load_config(cfg_path) + + assert cfg.paths.sessions == Path("sessions") + assert cfg.paths.artifacts == Path("artifacts") diff --git a/harness/tht/cli/__init__.py b/harness/tht/cli/__init__.py index f9c20444..3ed06d88 100644 --- a/harness/tht/cli/__init__.py +++ b/harness/tht/cli/__init__.py @@ -41,6 +41,7 @@ from tht.cli.cte_cmd import cte_app # noqa: E402 from tht.cli.datamart_cmd import datamart_app # noqa: E402 from tht.cli.db_cmd import db_app # noqa: E402 from tht.cli.decision_cmd import decision_app # noqa: E402 +from tht.cli.doctor_cmd import doctor # noqa: E402 from tht.cli.evidence_cmd import evidence_app # noqa: E402 from tht.cli.formula_cmd import formula_app # noqa: E402 from tht.cli.lsh_cmd import lsh_app # noqa: E402 @@ -69,3 +70,4 @@ app.add_typer(cte_app, name="cte") app.add_typer(datamart_app, name="datamart") app.add_typer(lsh_app, name="lsh") app.add_typer(ollama_app, name="ollama") +app.command("doctor")(doctor) diff --git a/harness/tht/cli/doctor_cmd.py b/harness/tht/cli/doctor_cmd.py new file mode 100644 index 00000000..f97bf469 --- /dev/null +++ b/harness/tht/cli/doctor_cmd.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import typer + +from tht.cli.config_cmd import CONFIG_OPT +from tht.config import ConfigError, load_config + + +def _emit(payload: dict[str, Any], as_json: bool) -> None: + if as_json: + # A single serializer call keeps stdout valid for machine consumers. + typer.echo(json.dumps(payload, sort_keys=True)) + return + for component, result in payload["components"].items(): + typer.echo(f"{component}: {result['status']}") + + +def doctor( + config: Path = CONFIG_OPT, + as_json: bool = typer.Option(False, "--json", help="Emette diagnostica JSON."), +) -> None: + """Validate portable storage configuration without contacting external services.""" + data_root = os.environ.get("THT_DATA_ROOT") + components: dict[str, dict[str, Any]] = { + "config": {"status": "ok"}, + "data_root": {"status": "ok" if data_root else "warning"}, + } + try: + cfg = load_config(config) + except ConfigError as exc: + path_error = "outside workspace root" in str(exc) + target = "workspace_paths" if path_error else "config" + # Validation errors can contain Pydantic input excerpts, including credentials. + message = str(exc) if path_error else "configuration is invalid" + components[target] = {"status": "error", "message": message} + payload = {"ok": False, "components": components} + _emit(payload, as_json) + raise typer.Exit(code=1) + + legacy_absolute = [ + name + for name in ("sessions", "artifacts", "indexes") + if getattr(cfg.roots, name).is_absolute() + ] + components["workspace_paths"] = { + "status": "warning" if legacy_absolute else "ok", + "legacy_absolute": legacy_absolute, + } + _emit({"ok": True, "components": components}, as_json) diff --git a/harness/tht/config.py b/harness/tht/config.py index 73ff727a..f4f5c986 100644 --- a/harness/tht/config.py +++ b/harness/tht/config.py @@ -254,6 +254,26 @@ def load_config(path: Path) -> Config: raise ConfigError( f"transport: rest richiede la sezione `rest` (base_url, api_key) in {path}." ) + data_root = os.environ.get("THT_DATA_ROOT") + if data_root: + # Import locally: paths owns resolution, while ConfigError remains the public + # configuration exception callers already handle. + from tht.paths import resolve_workspace_paths + + resolved = resolve_workspace_paths(path, cfg, Path(data_root)) + cfg = cfg.model_copy( + update={ + "paths": PathsConfig( + sessions=resolved.sessions, + artifacts=resolved.artifacts, + indexes=resolved.indexes, + ) + } + ) + elif not used_legacy: + # Modern `roots` replace `paths`; without a mounted data root retain the old + # working-directory-relative behavior used by local development. + cfg = cfg.model_copy(update={"paths": PathsConfig(**cfg.roots.model_dump())}) if used_legacy: warnings.warn( "DEPRECATION: legacy workspace resource keys are deprecated; " diff --git a/harness/tht/paths.py b/harness/tht/paths.py new file mode 100644 index 00000000..073c4ca9 --- /dev/null +++ b/harness/tht/paths.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from tht.config import ConfigError + +if TYPE_CHECKING: + from tht.config import Config + + +@dataclass(frozen=True) +class ResolvedPaths: + workspace: Path + sessions: Path + artifacts: Path + indexes: Path + corpus: Path + + +def _resolve_root(path: Path, workspace: Path, name: str) -> Path: + if path.is_absolute(): + # Existing deployments commonly point at an external workspace checkout. Keep + # those paths working while `tht doctor` identifies them for migration. + return path + + resolved = (workspace / path).resolve() + if not resolved.is_relative_to(workspace): + raise ConfigError(f"paths.{name} resolves outside workspace root") + return resolved + + +def resolve_workspace_paths( + config_path: Path, cfg: Config, data_root: Path +) -> ResolvedPaths: + """Resolve portable paths under ``/workspaces/``. + + Relative roots are sandboxed to the logical workspace. Absolute roots are a + compatibility bridge for existing installations and are never rewritten. + """ + workspace = (data_root / "workspaces" / config_path.stem).resolve() + roots = cfg.roots + return ResolvedPaths( + workspace=workspace, + sessions=_resolve_root(roots.sessions, workspace, "sessions"), + artifacts=_resolve_root(roots.artifacts, workspace, "artifacts"), + indexes=_resolve_root(roots.indexes, workspace, "indexes"), + corpus=(workspace / "corpus").resolve(), + )