feat(storage): resolve portable workspace roots
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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; "
|
||||
|
||||
@@ -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 ``<data_root>/workspaces/<config stem>``.
|
||||
|
||||
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(),
|
||||
)
|
||||
Reference in New Issue
Block a user