diff --git a/backend/fluksio/api/routes/flows.py b/backend/fluksio/api/routes/flows.py index 2e8be16..4303578 100644 --- a/backend/fluksio/api/routes/flows.py +++ b/backend/fluksio/api/routes/flows.py @@ -1,7 +1,9 @@ """The flow API. Everything the editor can do is available here first.""" import asyncio +import os import time +from pathlib import Path from typing import Any import orjson @@ -400,6 +402,34 @@ def _check_flavors(definition: FlowDef) -> None: ) +def _check_origin(definition: FlowDef) -> None: + """Refuse an ``origin.repo`` that is not a project's own directory. + + Every run of a code-defined flow hashes the python files below it, so + ``/`` or a home directory makes each run walk far more than the project, + and a relative path names nothing the engine can find. + """ + repo = definition.origin.repo if definition.origin is not None else "" + if not repo: + return + if not os.path.isabs(repo): + problem = "is a relative path" + elif os.path.realpath(repo) == "/": + problem = "is the filesystem root" + elif os.path.realpath(repo) == os.path.realpath(Path.home()): + problem = "is the home directory" + else: + return + raise HTTPException( + status_code=422, + detail=( + f"origin.repo '{repo}' {problem}. It has to be the absolute path " + "of the repository the flow is declared in, since every run hashes " + "the python files below it. Sync from inside that repository." + ), + ) + + def _worker_env(node: NodeDef | None) -> dict[str, str]: return node.resources.env if node is not None and node.resources else {} @@ -468,6 +498,7 @@ async def save_flow( raise HTTPException(status_code=400, detail="Node names must be unique") _check_flavors(definition) + _check_origin(definition) if not user.is_superuser: _refuse_code_changes(controller, name, definition) diff --git a/backend/tests/api/routes/test_sync.py b/backend/tests/api/routes/test_sync.py index 79c7985..0dbb977 100644 --- a/backend/tests/api/routes/test_sync.py +++ b/backend/tests/api/routes/test_sync.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient from fluksio.core.config import settings from fluksio.sdk import FLOWS, MARKER, SyncError -from fluksio.sdk.client import Client, sync +from fluksio.sdk.client import ApiError, Client, sync EXAMPLES = Path(__file__).parents[4] / "examples" PREFIX = settings.API_V1_STR @@ -47,6 +47,15 @@ def commits() -> int: return int(result.stdout.strip()) if result.returncode == 0 else 0 +@pytest.mark.parametrize("repo", ["/", str(Path.home()), "examples"]) +def test_sync_refuses_a_repo_every_run_would_walk_the_disk_for(api, flows, repo): + with pytest.raises(ApiError) as refused: + sync([flows["train"]], api, origin={**ORIGIN, "repo": repo}) + + assert refused.value.status == 422 + assert "origin.repo" in str(refused.value.detail) + + def test_sync_stores_a_runnable_flow_with_its_origin(api, flows): reports = sync([flows["train"]], api, origin=ORIGIN) diff --git a/docs/code/cli.md b/docs/code/cli.md index 34a8cd9..cbf3b1b 100644 --- a/docs/code/cli.md +++ b/docs/code/cli.md @@ -164,6 +164,11 @@ The generated node bodies import your functions. Keep the project available to each machine running them; sync does not copy your package to a remote worker. Uploading source requires a superuser token. +The flow records the git repository it was declared in, or the directory when +there is none, and each run hashes the Python files below it. The engine +refuses `/`, its home directory and relative paths there, so run sync from +inside the project's repository. + | Flag | Purpose | |---|---| | `--dry-run` | Print flow documents and node bodies without uploading |