Refuse an origin.repo of /, the home directory or a relative path

Every run hashes the python files below origin.repo, so saving or syncing
a flow with one of those now answers 422 with what to do instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 10:55:40 +02:00
co-authored by Claude Opus 5.5
parent 068ff45108
commit 7f99450593
3 changed files with 46 additions and 1 deletions
+31
View File
@@ -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)
+10 -1
View File
@@ -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)
+5
View File
@@ -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 |