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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user