fix: keep Langflow flow names in sync files, write them world-readable
This commit is contained in:
parent
95a1c1afb7
commit
3ee997cb72
8 changed files with 58 additions and 769 deletions
|
|
@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
zusätzlich Pull-Dedup (wiederverwendet gleichnamige/identische Dateien
|
||||
statt Bindestrich-Duplikate wie `simple-rule.json`, die jede Sync-Meldung
|
||||
mit Namenskonflikten füllten).
|
||||
- Sync-Dateinamen behalten die Langflow-Namen (`test_flow.json` statt
|
||||
`test-flow.json`; nur dateisystem-reservierte Zeichen werden ersetzt).
|
||||
- Gezogene Flow-Dateien sind world-readable (0o644): `mkstemp` erzeugte
|
||||
0o600-Dateien, die Langflow beim Start abstürzen ließen
|
||||
(`Permission denied` in `/app/flows`, Crash-Loop) und der API Fehler
|
||||
(`unreadable`) verursachten.
|
||||
- LFX runtime user gets a home directory (`useradd -m`, `HOME` set):
|
||||
without one every flow run failed with "Settings service not found".
|
||||
### Security
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ async def pull_all_from_langflow(
|
|||
if identical and reuse is not None:
|
||||
pulled.append(reuse.stem)
|
||||
continue
|
||||
target = reuse or export.flow_path(flows_dir, export.slugify(doc["name"]))
|
||||
target = reuse or export.flow_path(flows_dir, export.filename_stem(doc["name"]))
|
||||
path = export.write_flow_bytes(target, doc)
|
||||
pulled.append(path.stem)
|
||||
except (LangflowError, OSError, ValueError) as exc:
|
||||
|
|
|
|||
|
|
@ -16,10 +16,17 @@ from pathlib import Path
|
|||
FLOWS_GLOB = "*.json"
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Map a flow name to a safe filename stem (lowercase, [a-z0-9-])."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return slug or "flow"
|
||||
def filename_stem(name: str) -> str:
|
||||
"""Map a flow name to a filename stem, keeping the original name.
|
||||
|
||||
Only filesystem-reserved characters (``/ \\ : * ? " < > |`` and control
|
||||
chars) become ``-``; leading/trailing whitespace and trailing dots are
|
||||
stripped (Windows). Case, spaces, underscores and umlauts are preserved:
|
||||
``test_flow`` stays ``test_flow``.
|
||||
"""
|
||||
stem = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "-", name).strip()
|
||||
stem = stem.rstrip(".")
|
||||
return stem or "flow"
|
||||
|
||||
|
||||
def canonical_bytes(doc: dict) -> bytes:
|
||||
|
|
@ -70,7 +77,12 @@ def find_reuse_path(flows_dir: str | Path, doc: dict) -> tuple[Path | None, bool
|
|||
|
||||
|
||||
def write_flow_bytes(target: Path, doc: dict) -> Path:
|
||||
"""Write a flow document to an explicit path atomically (tmp + rename)."""
|
||||
"""Write a flow document to an explicit path atomically (tmp + rename).
|
||||
|
||||
The file ends up world-readable (0o644): mkstemp creates 0o600, which
|
||||
would lock out the other containers (Langflow/LFX run as different
|
||||
users and crash on unreadable seed files).
|
||||
"""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(target.parent), prefix=f".{target.stem}-", suffix=".tmp"
|
||||
|
|
@ -79,6 +91,7 @@ def write_flow_bytes(target: Path, doc: dict) -> Path:
|
|||
with os.fdopen(fd, "wb") as tmp:
|
||||
tmp.write(canonical_bytes(doc))
|
||||
os.replace(tmp_name, target)
|
||||
os.chmod(target, 0o644)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
|
|
@ -96,7 +109,7 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None
|
|||
"""
|
||||
flows_dir = Path(flows_dir)
|
||||
flows_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = slugify(name or doc.get("name") or "flow")
|
||||
stem = filename_stem(name or doc.get("name") or "flow")
|
||||
return write_flow_bytes(flow_path(flows_dir, stem), doc)
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5,11 +5,15 @@ import json
|
|||
from app.flows import export
|
||||
|
||||
|
||||
def test_slugify():
|
||||
assert export.slugify("Dachsanierung 200k") == "dachsanierung-200k"
|
||||
assert export.slugify(" A/B\\C ") == "a-b-c"
|
||||
assert export.slugify("simple_rule") == "simple-rule"
|
||||
assert export.slugify("") == "flow"
|
||||
def test_filename_stem_keeps_original_names():
|
||||
assert export.filename_stem("simple_rule") == "simple_rule"
|
||||
assert export.filename_stem("test_flow") == "test_flow"
|
||||
assert export.filename_stem("Gehaltserhöhung") == "Gehaltserhöhung"
|
||||
assert export.filename_stem("Dachsanierung 200k") == "Dachsanierung 200k"
|
||||
assert export.filename_stem(" A/B\\C ") == "A-B-C"
|
||||
assert export.filename_stem('a:b*c?d"e<f>g|h') == "a-b-c-d-e-f-g-h"
|
||||
assert export.filename_stem("trailing...") == "trailing"
|
||||
assert export.filename_stem("") == "flow"
|
||||
|
||||
|
||||
def test_canonical_bytes_stable_under_key_reorder():
|
||||
|
|
@ -22,7 +26,7 @@ def test_canonical_bytes_stable_under_key_reorder():
|
|||
def test_write_and_read_roundtrip_is_byte_stable(tmp_path):
|
||||
doc = {"name": "My Flow", "data": {"nodes": [], "edges": []}}
|
||||
path = export.write_flow_file(tmp_path, doc)
|
||||
assert path == tmp_path / "my-flow.json"
|
||||
assert path == tmp_path / "My Flow.json"
|
||||
assert json.loads(path.read_text()) == doc
|
||||
# Rewriting identical content is byte-identical (no phantom git diffs).
|
||||
before = path.read_bytes()
|
||||
|
|
@ -64,3 +68,15 @@ def test_find_reuse_path_no_match_and_missing_dir(tmp_path):
|
|||
def test_find_reuse_path_skips_unreadable_files(tmp_path):
|
||||
(tmp_path / "kaputt.json").write_text("{kein json")
|
||||
assert export.find_reuse_path(tmp_path, {"name": "Neu"}) == (None, False)
|
||||
|
||||
|
||||
def test_write_flow_file_is_world_readable(tmp_path):
|
||||
import os
|
||||
import stat
|
||||
|
||||
doc = {"name": "Lesbar", "data": {"nodes": [], "edges": []}}
|
||||
path = export.write_flow_file(tmp_path, doc)
|
||||
mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||
# Read bits for user/group/other (mkstemp defaults to 0o600; on Windows
|
||||
# chmod maps to 0o666, so only assert the read bits, not the exact mode).
|
||||
assert mode & 0o444 == 0o444, f"expected world-readable, got {oct(mode)}"
|
||||
|
|
|
|||
|
|
@ -163,17 +163,17 @@ async def test_sync_endpoint_pulls_from_langflow_then_syncs(
|
|||
response = await client.post("/api/flows/sync")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["pulled"] == ["gehaltserh-hung"]
|
||||
assert body["added"] == ["gehaltserh-hung"]
|
||||
assert body["pulled"] == ["Gehaltserhöhung"]
|
||||
assert body["added"] == ["Gehaltserhöhung"]
|
||||
assert body["errors"] == {}
|
||||
assert (tmp_path / "gehaltserh-hung.json").is_file()
|
||||
assert (tmp_path / "Gehaltserhöhung.json").is_file()
|
||||
|
||||
# Second pass: same content pulls again but syncs nothing new.
|
||||
response = await client.post("/api/flows/sync")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["pulled"] == ["gehaltserh-hung"]
|
||||
assert body["skipped"] == ["gehaltserh-hung"]
|
||||
assert body["pulled"] == ["Gehaltserhöhung"]
|
||||
assert body["skipped"] == ["Gehaltserhöhung"]
|
||||
|
||||
|
||||
async def test_sync_endpoint_pull_reuses_same_named_file(
|
||||
|
|
@ -214,8 +214,8 @@ async def test_sync_endpoint_pull_error_continues(
|
|||
response = await client.post("/api/flows/sync")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["pulled"] == ["gut"]
|
||||
assert body["added"] == ["gut"]
|
||||
assert body["pulled"] == ["Gut"]
|
||||
assert body["added"] == ["Gut"]
|
||||
assert "kaputt-id" in body["errors"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -62,8 +62,9 @@ export default function FlowHelp() {
|
|||
</Title>
|
||||
<List size="sm" spacing="xs">
|
||||
<List.Item>
|
||||
Der Dateiname wird aus dem Flow-Namen erzeugt (Kleinbuchstaben,
|
||||
Bindestriche). Umbenennen erzeugt eine neue Datei.
|
||||
Die Datei heißt wie der Flow in Langflow (z. B. test_flow.json).
|
||||
Umbenennen in Langflow erzeugt eine neue Datei; die alte bleibt
|
||||
bestehen und muss bei Bedarf gelöscht werden.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
Eine gelöschte Datei löscht den Flow inklusive seiner Läufe
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue