Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions app/routes/v1/catalog/entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,36 @@ def _entry_from_meta_main_yml(p: Path, repo_dir: Path) -> dict | None:
}


def _detail_at_path(repo_dir: Path, path: str) -> dict | None:
"""Resolve a single entry at ``path`` across the three manifest formats.

Mirrors :func:`_discover`'s recognition so the detail endpoint surfaces
every entry the browse endpoint lists — not just ``range42.yaml``.
Returns the summary dict plus a parsed ``document``, or ``None`` if no
recognised manifest exists at ``path``.
"""
base = repo_dir / path
candidates = [
(base / "range42.yaml", _entry_from_range42_yaml, _yaml.load),
(base / "meta.json", _entry_from_meta_json, json.loads),
(base / "meta" / "main.yml", _entry_from_meta_main_yml, _yaml.load),
]
for manifest, parse_entry, load_doc in candidates:
if not manifest.exists():
continue
entry = parse_entry(manifest, repo_dir)
if entry is None:
continue
doc = load_doc(manifest.read_text())
# CatalogEntryDetail.document is typed dict; a syntactically valid but
# non-mapping top level (e.g. a YAML/JSON list) would otherwise fail
# response validation. Coerce it to {} — the entry still resolves.
if not isinstance(doc, dict):
doc = {}
return {**entry, "document": doc}
return None


def _discover(repo_dir: Path) -> list[dict]:
"""Walk ``repo_dir`` for the three known manifest types.

Expand Down Expand Up @@ -210,19 +240,18 @@ async def get_entry(
workdir = Path(td)
for repo in repos:
dest = _clone_repo(src, repo, workdir)
manifest = dest / path / "range42.yaml"
if manifest.exists():
doc = _yaml.load(manifest.read_text()) or {}
entry = _detail_at_path(dest, path)
if entry is not None:
readme = dest / path / "README.md"
return CatalogEntryDetail(
source_id=src.id,
path=path,
kind=doc.get("kind", "unknown"),
name=doc.get("name", path),
description=doc.get("description"),
tags=doc.get("tags", []),
path=entry["path"],
kind=entry["kind"],
name=entry["name"],
description=entry.get("description"),
tags=entry.get("tags") or [],
sha=None,
document=doc,
document=entry["document"],
readme_md=readme.read_text() if readme.exists() else None,
)
raise Range42Error(
Expand Down
20 changes: 13 additions & 7 deletions app/routes/v1/catalog/refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ async def refresh_source(
)


def _count_manifests(root: "Path") -> int:
"""Count catalog entries under ``root`` using the shared walker.

Delegates to :func:`app.routes.v1.catalog.entries._discover` so the
refresh count always agrees with what ``/v1/catalog/entries`` surfaces
(range42.yaml, meta.json, and meta/main.yml — not just range42.yaml).
"""
from app.routes.v1.catalog.entries import _discover

return len(_discover(root))


def _count_entries_in_repo(src: Source, repo: SourceRepo) -> int:
import tempfile
from pathlib import Path
Expand All @@ -78,10 +90,4 @@ def _count_entries_in_repo(src: Source, repo: SourceRepo) -> int:
url = f"{str(src.base_url).rstrip('/')}/{repo.owner}/{repo.repo}.git"
with tempfile.TemporaryDirectory() as td:
git.Repo.clone_from(url, td, depth=1, branch=repo.branch)
root = Path(td)
count = 0
for _p in root.rglob("range42.yaml"):
count += 1
for _p in root.rglob("catalog.yaml"):
count += 1
return count
return _count_manifests(Path(td))
9 changes: 7 additions & 2 deletions tests/core/test_alembic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import subprocess
import sys
from pathlib import Path


Expand All @@ -7,8 +8,12 @@ def test_alembic_upgrade_downgrade_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv("RANGE42_DB_URL", f"sqlite+aiosqlite:///{db}")
monkeypatch.setenv("RANGE42_WORKSPACE_ROOT", str(tmp_path))
repo = Path(__file__).resolve().parents[2]
r = subprocess.run(["alembic", "upgrade", "head"], cwd=repo, check=True,
# Invoke alembic via the active interpreter (sys.executable -m) so the
# migration runs against the venv's SQLAlchemy, not whatever bare
# ``alembic`` happens to resolve to on PATH (e.g. a stale ~/.local copy).
alembic = [sys.executable, "-m", "alembic"]
r = subprocess.run(alembic + ["upgrade", "head"], cwd=repo, check=True,
capture_output=True, text=True)
assert db.exists()
r = subprocess.run(["alembic", "downgrade", "base"], cwd=repo, check=True,
r = subprocess.run(alembic + ["downgrade", "base"], cwd=repo, check=True,
capture_output=True, text=True)
79 changes: 79 additions & 0 deletions tests/routes/test_catalog_detail_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Entry-detail resolution must recognise all three manifest formats.

The detail endpoint used to only read ``range42.yaml`` at the requested
path, so every container (``meta.json``) or Ansible-role (``meta/main.yml``)
entry that the browse endpoint surfaced 404'd on detail. ``_detail_at_path``
must resolve all three, returning the parsed document alongside the summary
fields.
"""
from __future__ import annotations

import json

from app.routes.v1.catalog.entries import _detail_at_path


def _write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)


def test_detail_resolves_range42_yaml(tmp_path):
_write(tmp_path / "sub_a" / "range42.yaml", "kind: lab\nname: x\n")
d = _detail_at_path(tmp_path, "sub_a")
assert d is not None
assert d["kind"] == "lab"
assert d["name"] == "x"
assert d["document"]["kind"] == "lab"


def test_detail_resolves_meta_json_container(tmp_path):
_write(
tmp_path / "sub_b" / "meta.json",
json.dumps(
{
"x_range42": {
"exercise": {"id": "exer-1"},
"catalog": {"tags": ["t1"]},
"vuln": {"title": "vuln title"},
}
}
),
)
d = _detail_at_path(tmp_path, "sub_b")
assert d is not None
assert d["kind"] == "container"
assert d["name"] == "exer-1"
assert d["description"] == "vuln title"
assert d["tags"] == ["t1"]
assert d["document"]["x_range42"]["exercise"]["id"] == "exer-1"


def test_detail_resolves_meta_main_yml_role(tmp_path):
_write(
tmp_path / "sub_c" / "meta" / "main.yml",
"galaxy_info:\n description: role d\n galaxy_tags:\n - g1\n",
)
d = _detail_at_path(tmp_path, "sub_c")
assert d is not None
assert d["kind"] == "ansible_role"
assert d["name"] == "sub_c"
assert d["description"] == "role d"
assert d["tags"] == ["g1"]
assert d["document"]["galaxy_info"]["description"] == "role d"


def test_detail_missing_path_returns_none(tmp_path):
_write(tmp_path / "sub_a" / "range42.yaml", "kind: lab\nname: x\n")
assert _detail_at_path(tmp_path, "nope") is None


def test_detail_non_dict_document_is_coerced_to_dict(tmp_path):
# A syntactically valid manifest whose top level is a (truthy) non-dict,
# e.g. a JSON array. The browse walker still synthesises a summary for it,
# so detail must return a CatalogEntryDetail-compatible document (a dict),
# not the raw list — otherwise response validation 500s on document: dict.
_write(tmp_path / "weird" / "meta.json", "[1, 2, 3]")
d = _detail_at_path(tmp_path, "weird")
assert d is not None
assert isinstance(d["document"], dict)
31 changes: 31 additions & 0 deletions tests/routes/test_catalog_refresh_count.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Refresh entry-count must match the walker's manifest recognition.

The refresh endpoint reports ``entries_indexed`` after shallow-cloning each
repo. That count must agree with what ``/v1/catalog/entries`` would surface,
i.e. it must recognise all three manifest formats (range42.yaml, meta.json,
meta/main.yml) — not just range42.yaml.
"""
from __future__ import annotations

import json

from app.routes.v1.catalog.refresh import _count_manifests


def _write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)


def test_count_manifests_recognises_all_three_formats(tmp_path):
_write(tmp_path / "sub_a" / "range42.yaml", "kind: lab\nname: x\n")
_write(
tmp_path / "sub_b" / "meta.json",
json.dumps({"x_range42": {"exercise": {"id": "exer-1"}}}),
)
_write(
tmp_path / "sub_c" / "meta" / "main.yml",
"galaxy_info:\n description: role d\n",
)

assert _count_manifests(tmp_path) == 3