From 1b5aa90dbeb437e7f54da56b349f362a978bda2c Mon Sep 17 00:00:00 2001 From: Christian Anetzberger Date: Mon, 26 Jan 2026 20:33:52 +0100 Subject: [PATCH] Dockerized --- .dockerignore | 10 +++ .gitignore | 94 ++++++++++++++------- Dockerfile | 46 +++++++++++ README.md | 3 + app.py | 200 +++++++++++++++++++++++++++++---------------- docker-compose.yml | 22 +++++ install.sh | 2 - requirements.txt | 1 + start.sh | 2 - 9 files changed, 279 insertions(+), 101 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml delete mode 100755 install.sh delete mode 100755 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f66721a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.venv +__pycache__ +_jobs +.git +.DS_Store +*.log +*.FCStd +*.dxf +*.svg +*.json diff --git a/.gitignore b/.gitignore index dfeb4b6..fb40b18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,15 @@ +# ========================= +# OS / Editor +# ========================= +.DS_Store +.AppleDouble +.LSOverride +Thumbs.db + +.vscode/ +.idea/ +*.code-workspace + # ========================= # Python # ========================= @@ -5,55 +17,81 @@ __pycache__/ *.py[cod] *$py.class -# Virtual environments .venv/ venv/ env/ +ENV/ + +pip-wheel-metadata/ +*.egg-info/ +.eggs/ +build/ +dist/ + +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +coverage/ +.coverage* +htmlcov/ # ========================= -# macOS -# ========================= -.DS_Store -.AppleDouble -.LSOverride - -# ========================= -# IDEs / Editors -# ========================= -.vscode/ -.idea/ -*.swp -*.swo - -# ========================= -# Logs +# Logs / runtime # ========================= *.log +*.pid # ========================= -# Job runtime data (generated) +# App runtime data (IMPORTANT) # ========================= +# Job artifacts: uploads, results, dxf/svg previews, logs etc. _jobs/ _jobs/** +# If you keep a local config volume for dev +_config/ +_config/** + +# Temp files +tmp/ +temp/ +*.tmp + # ========================= -# Generated outputs +# CAD / output files # ========================= *.dxf *.svg -*.json -*.FCStd +*.step +*.stp +*.iges +*.igs +*.stl +*.obj -# If you want to keep example outputs, comment out the lines above selectively +# If you want to keep a sample file, commit it explicitly with: +# git add -f test.step # ========================= -# Temporary files +# Docker # ========================= -*.tmp -*.bak -*.old +# Local overrides +docker-compose.override.yml # ========================= -# OS / misc +# Node (if you ever add frontend tooling) # ========================= -Thumbs.db +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# ========================= +# Secrets (safety) +# ========================= +.env +.env.* +*.pem +*.key +*.crt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..01e1aa2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM mambaorg/micromamba:1.5.10 + +WORKDIR /app + +# Install FreeCAD + Python via conda-forge (headless) +RUN micromamba create -y -n fc -c conda-forge \ + python=3.12 freecad \ + && micromamba clean -a -y + +# Activate env for subsequent RUN/CMD +ENV MAMBA_DOCKERFILE_ACTIVATE=1 +SHELL ["/bin/bash", "-lc"] + +# Install small OS deps (git for SheetMetal) +# NOTE: do NOT switch users; keep it simple and reproducible +USER root +RUN mkdir -p /var/lib/apt/lists/partial \ + && apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Python deps into conda env +COPY requirements.txt /app/requirements.txt +RUN micromamba run -n fc pip install --no-cache-dir -r /app/requirements.txt + +# App sources +COPY . /app + +# SheetMetal workbench (we'll link it into HOME at runtime) +RUN mkdir -p /opt/freecad_mods \ + && git clone --depth 1 https://github.com/shaise/FreeCAD_SheetMetal.git /opt/freecad_mods/SheetMetal + +# Make FreeCAD find SheetMetal without any host volume +ENV HOME=/opt/freecad_home +RUN mkdir -p /opt/freecad_home/.local/share/FreeCAD/Mod \ + && ln -s /opt/freecad_mods/SheetMetal /opt/freecad_home/.local/share/FreeCAD/Mod/SheetMetal + +# Runtime env (overridable) +ENV FREECADCMD=/opt/conda/envs/fc/bin/freecadcmd +ENV FREECAD_TIMEOUT_SEC=1200 +ENV FREECAD_LOCK_FILE=/app/_jobs/.freecad.lock +ENV HOME=/config + +EXPOSE 5055 + +CMD ["bash", "-lc", "micromamba run -n fc gunicorn -w 1 --threads 4 -b 0.0.0.0:5055 app:app"] \ No newline at end of file diff --git a/README.md b/README.md index e69de29..21b53b5 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,3 @@ +Internes Tool zur Kalkulation von Blechteilen. + +Derzeit keine Unterstützung von Baugruppen! diff --git a/app.py b/app.py index 27b94f5..3c12509 100644 --- a/app.py +++ b/app.py @@ -4,33 +4,37 @@ import uuid import shutil import subprocess from pathlib import Path +import fcntl from flask import Flask, render_template, request, abort, Response from werkzeug.utils import secure_filename import ezdxf -from ezdxf.addons.drawing import Frontend, RenderContext, svg, layout, config +from ezdxf.addons.drawing import Frontend, RenderContext, svg, layout, config as ezconfig APP_DIR = Path(__file__).resolve().parent WORKER_DIR = APP_DIR / "worker" - -FREECADCMD = "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" - JOBS_DIR = APP_DIR / "_jobs" JOBS_DIR.mkdir(exist_ok=True) +# In Docker: /usr/bin/freecadcmd, lokal (mac): via env setzen, wenn du willst +FREECAD_TIMEOUT_SEC = int(os.environ.get("FREECAD_TIMEOUT_SEC", "1200")) +FREECADCMD = (os.environ.get("FREECADCMD") or "").strip() or "/opt/conda/envs/fc/bin/freecadcmd" + ALLOWED_MATERIALS = [ ("stainless", "Edelstahl"), ("alu", "Aluminium"), ("copper", "Kupfer"), ] +# Serialize FreeCAD runs (one at a time) +LOCK_FILE = os.environ.get("FREECAD_LOCK_FILE", str(JOBS_DIR / ".freecad.lock")) + app = Flask(__name__) def _safe_job_dir(job_id: str) -> Path: - # minimal validation to avoid path traversal if not job_id or any(c not in "0123456789abcdef" for c in job_id.lower()): abort(404) job_dir = (JOBS_DIR / job_id).resolve() @@ -41,13 +45,31 @@ def _safe_job_dir(job_id: str) -> Path: return job_dir +class FreeCADLock: + def __init__(self, lock_path: str): + self.lock_path = lock_path + self.fp = None + + def __enter__(self): + self.fp = open(self.lock_path, "w") + fcntl.flock(self.fp, fcntl.LOCK_EX) + return self + + def __exit__(self, exc_type, exc, tb): + try: + fcntl.flock(self.fp, fcntl.LOCK_UN) + finally: + try: + self.fp.close() + except Exception: + pass + + def run_job(job_dir: Path, filename: str, material: str, thickness_mm: str | None): """ Runs FreeCAD headless in a job dir. Keeps original filename. - Expects: job_dir/filename exists. - Produces: job_dir/result.json (compat), flat.dxf (compat), plus named outputs. + Produces: job_dir/result.json + flat.dxf (compat), plus named outputs by analyser. """ - # Copy worker script into job dir shutil.copy2(WORKER_DIR / "stepanalyser.py", job_dir / "stepanalyser.py") argv = ['"stepanalyser.py"', '"--input"', f'"{filename}"', '"--material"', f'"{material}"'] @@ -58,7 +80,7 @@ def run_job(job_dir: Path, filename: str, material: str, thickness_mm: str | Non def write_result(payload): try: - with open("result.json", "w", encoding="utf-8") as f: + with open(\"result.json\", \"w\", encoding=\"utf-8\") as f: json.dump(payload, f, indent=2, ensure_ascii=False) except Exception: pass @@ -68,87 +90,143 @@ try: os.chdir(base) if not os.path.exists({json.dumps(filename)}): - raise SystemExit("Uploaded STEP file missing: " + {json.dumps(filename)}) + raise SystemExit(\"Uploaded STEP file missing: \" + {json.dumps(filename)}) # Ensure FreeCAD can find user Mods (SheetMetal) - mod_dir = os.path.expanduser("~/Library/Application Support/FreeCAD/Mod") + # Linux default: ~/.local/share/FreeCAD/Mod + mod_dir = os.path.expanduser("~/.local/share/FreeCAD/Mod") if os.path.isdir(mod_dir) and mod_dir not in sys.path: sys.path.append(mod_dir) - sm_dir = os.path.join(mod_dir, "SheetMetal") + sm_dir = os.path.join(mod_dir, \"SheetMetal\") if os.path.isdir(sm_dir) and sm_dir not in sys.path: sys.path.append(sm_dir) sys.argv = [{", ".join(argv)}] - code = open("stepanalyser.py", "r", encoding="utf-8").read() - exec(compile(code, "stepanalyser.py", "exec"), {{"__name__": "__main__"}}) + code = open(\"stepanalyser.py\", \"r\", encoding=\"utf-8\").read() + exec(compile(code, \"stepanalyser.py\", \"exec\"), {{\"__name__\": \"__main__\"}}) except BaseException as e: payload = {{ - "ok": False, - "error_type": type(e).__name__, - "error": str(e), - "traceback": traceback.format_exc() + \"ok\": False, + \"error_type\": type(e).__name__, + \"error\": str(e), + \"traceback\": traceback.format_exc() }} write_result(payload) - print("RUNNER ERROR:", payload["error_type"], payload["error"], flush=True) + print(\"RUNNER ERROR:\", payload[\"error_type\"], payload[\"error\"], flush=True) finally: os._exit(0) """ + (job_dir / "run_stepanalyser.py").write_text(runner_text, encoding="utf-8") cmd = [FREECADCMD, "run_stepanalyser.py"] env = os.environ.copy() - env["HOME"] = str(Path.home()) + # In linuxserver containers the writable, persistent home is /config + env["HOME"] = env.get("HOME") or "/config" + env["HOME"] = "/config" # enforce for consistent expanduser() + Mod discovery - proc = subprocess.run( - cmd, - cwd=str(job_dir), - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - timeout=180, - env=env, - ) + # Headless Qt hints (works on Debian-based hosts too) + env.setdefault("QT_QPA_PLATFORM", "offscreen") - log_text = proc.stdout or "" - (job_dir / "run.log").write_text(log_text, encoding="utf-8") + timeout = FREECAD_TIMEOUT_SEC + log_path = job_dir / "run.log" result_path = job_dir / "result.json" + + # One FreeCAD run at a time + with FreeCADLock(LOCK_FILE): + with open(log_path, "w", encoding="utf-8") as log_fp: + log_fp.write("=== STEPANALYSER START ===\n") + log_fp.write(f"Command: {cmd}\n") + log_fp.flush() + + proc = subprocess.Popen( + cmd, + cwd=str(job_dir), + stdin=subprocess.DEVNULL, + stdout=log_fp, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + # Kill the process and persist a useful error result + try: + proc.kill() + except Exception: + pass + + try: + log_fp.write(f"\nTIMEOUT: FreeCAD run exceeded {timeout} seconds and was killed.\n") + log_fp.flush() + except Exception: + pass + + tail = "" + try: + tail = log_path.read_text(encoding="utf-8")[-8000:] + except Exception: + pass + + err = { + "ok": False, + "error_type": "TimeoutExpired", + "error": f"FreeCAD job timed out after {timeout} seconds", + "log_tail": tail, + } + try: + result_path.write_text(json.dumps(err, indent=2, ensure_ascii=False), encoding="utf-8") + except Exception: + pass + + err["_job"] = { + "id": job_dir.name, + "dir": str(job_dir), + "exit_code": None, + "log_file": str(log_path), + "compat_dxf": str(job_dir / "flat.dxf"), + "compat_json": str(result_path), + "svg_preview_url": f"/job/{job_dir.name}/dxf.svg", + "dxf_download_url": f"/job/{job_dir.name}/flat.dxf", + } + return err + + # Normal completion path + exit_code = proc.returncode + if result_path.exists(): result = json.loads(result_path.read_text(encoding="utf-8")) else: + tail = "" + try: + tail = log_path.read_text(encoding="utf-8")[-4000:] + except Exception: + pass result = { "ok": False, "error_type": "RunnerError", "error": "result.json not created", - "log_tail": log_text[-4000:], + "log_tail": tail, } result["_job"] = { "id": job_dir.name, "dir": str(job_dir), - "exit_code": proc.returncode, - "log_file": str(job_dir / "run.log"), + "exit_code": exit_code, + "log_file": str(log_path), "compat_dxf": str(job_dir / "flat.dxf"), - "compat_json": str(job_dir / "result.json"), + "compat_json": str(result_path), "svg_preview_url": f"/job/{job_dir.name}/dxf.svg", "dxf_download_url": f"/job/{job_dir.name}/flat.dxf", } - # If analyser wrote named outputs, try to attach them (best effort) - try: - if "output" in result and isinstance(result["output"], dict): - result["_job"]["named_dxf"] = result["output"].get("dxf_named") - result["_job"]["named_json"] = result["output"].get("json_named") - result["_job"]["named_fcstd"] = result["output"].get("fcstd_named") - result["_job"]["compat_fcstd"] = result["output"].get("fcstd") - except Exception: - pass - return result @@ -156,39 +234,27 @@ def dxf_to_svg_bytes(dxf_path: Path) -> bytes: doc = ezdxf.readfile(str(dxf_path)) msp = doc.modelspace() - # 1) Context context = RenderContext(doc) - - # 2) Backend backend = svg.SVGBackend() - # 3) Config: weißer Hintergrund + schwarz (super für Blechteile) - cfg = config.Configuration( - background_policy=config.BackgroundPolicy.WHITE, - color_policy=config.ColorPolicy.BLACK, + cfg = ezconfig.Configuration( + background_policy=ezconfig.BackgroundPolicy.WHITE, + color_policy=ezconfig.ColorPolicy.BLACK, ) - # 4) Frontend frontend = Frontend(context, backend, config=cfg) - - # 5) Zeichnen frontend.draw_layout(msp) - # 6) Page: auto-detect size (0,0) + kleine Ränder page = layout.Page(0, 0, layout.Units.mm, margins=layout.Margins.all(2)) - - # Optional: fit to page (preview-geeignet) try: settings = layout.Settings(scale=1, fit_page=True) svg_string = backend.get_string(page, settings=settings) except TypeError: - # ältere ezdxf-Version ohne settings-Parameter svg_string = backend.get_string(page) return svg_string.encode("utf-8") - @app.get("/") def index(): return render_template("index.html", materials=ALLOWED_MATERIALS) @@ -214,12 +280,13 @@ def analyse(): job_dir = JOBS_DIR / job_id job_dir.mkdir(parents=True, exist_ok=True) - # Save upload using original (safe) filename step_dest = job_dir / filename f.save(str(step_dest)) thickness_mm = thickness if thickness else None result = run_job(job_dir, filename, material, thickness_mm) + if not isinstance(result, dict): + result = {"ok": False, "error_type": "InternalError", "error": "Unexpected result type"} return render_template("result.html", result=result) @@ -248,16 +315,11 @@ def preview_dxf_svg(job_id: str): try: svg_bytes = dxf_to_svg_bytes(dxf_path) except Exception as e: - # Return a simple SVG with an error message msg = f"DXF->SVG failed: {type(e).__name__}: {e}" - svg = f""" + svg_fallback = f""" {msg} """ - return Response(svg.encode("utf-8"), mimetype="image/svg+xml") + return Response(svg_fallback.encode("utf-8"), mimetype="image/svg+xml") return Response(svg_bytes, mimetype="image/svg+xml") - - -if __name__ == "__main__": - app.run(host="127.0.0.1", port=5055, debug=True) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7783474 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + stepanalyser: + build: . + container_name: stepanalyser + ports: + - "5055:5055" + volumes: + - ./_jobs:/app/_jobs + environment: + - TZ=Europe/Berlin + - FREECADCMD=/opt/conda/envs/fc/bin/freecadcmd + - FREECAD_TIMEOUT_SEC=1200 + - FREECAD_LOCK_FILE=/app/_jobs/.freecad.lock + command: > + bash -lc ' + mkdir -p /config/.local/share/FreeCAD/Mod && + if [ ! -e /config/.local/share/FreeCAD/Mod/SheetMetal ]; then + ln -s /opt/freecad_mods/SheetMetal /config/.local/share/FreeCAD/Mod/SheetMetal; + fi && + micromamba run -n fc gunicorn -w 1 --threads 4 -b 0.0.0.0:5055 app:app + ' + restart: unless-stopped diff --git a/install.sh b/install.sh deleted file mode 100755 index 22b32c3..0000000 --- a/install.sh +++ /dev/null @@ -1,2 +0,0 @@ -source .venv/bin/activate -pip install -r requirements.txt \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e05ffee..82c12b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ flask==3.0.3 +gunicorn==22.0.0 ezdxf==1.3.4 pillow==10.4.0 \ No newline at end of file diff --git a/start.sh b/start.sh deleted file mode 100755 index 15c42c1..0000000 --- a/start.sh +++ /dev/null @@ -1,2 +0,0 @@ -source .venv/bin/activate -python app.py