diff --git a/compose/hf-pdocker-02/lifeos/Dockerfile b/compose/hf-pdocker-02/lifeos/Dockerfile new file mode 100644 index 0000000..fe086b3 --- /dev/null +++ b/compose/hf-pdocker-02/lifeos/Dockerfile @@ -0,0 +1,51 @@ +# LifeOS — https://github.com/lifeos-app/lifeos +# +# Two stages: Node builds the Vite/React bundle, Python serves that bundle plus +# the Flask API from a single process. Build context is the upstream source tree +# (cloned to /opt/lifeos on the node); this file is copied in alongside it. + +# ─── Stage 1: build the frontend ────────────────────────────────────── +FROM node:22-bookworm-slim AS frontend +WORKDIR /build + +COPY package.json package-lock.json ./ +# --ignore-scripts skips the electron-builder postinstall, which pulls a desktop +# toolchain we never use and fails without an X stack. +RUN npm ci --ignore-scripts + +COPY . . + +# Routes the data layer (src/lib/data-access.ts) to the local Flask API instead +# of Supabase Cloud. VITE_API_BASE_URL is deliberately left unset: local-api.ts +# then falls back to a relative /api, so the bundle works on any host and port. +ENV VITE_USE_LOCAL_API=true +ENV NODE_OPTIONS=--max-old-space-size=3072 +RUN npm run build + +# ─── Stage 2: Flask API + built frontend ────────────────────────────── +FROM python:3.12-slim +WORKDIR /app + +COPY backend/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt gunicorn + +COPY backend/ /app/ +COPY serve_container.py /app/ +COPY --from=frontend /build/dist /app/dist + +# Upstream hardcodes the author's own model name in the chat proxy, and a 30s +# timeout that a cold Ollama model load (~46s for a 9.6GB model) blows through. +# Make both env vars. Fails the build if upstream renames these out from under us. +RUN sed -i 's/"model": "sentient-teddy"/"model": os.environ.get("AI_MODEL", "sentient-teddy")/' app.py \ + && sed -i 's/timeout=30$/timeout=int(os.environ.get("AI_TIMEOUT", "120"))/' app.py \ + && grep -q 'os.environ.get("AI_MODEL"' app.py \ + && grep -q 'os.environ.get("AI_TIMEOUT"' app.py + +# app.py hardcodes its SQLite path to ~/.lifeos/data.db, so HOME is what pins +# the database into the mounted volume. +ENV HOME=/data +RUN mkdir -p /data/.lifeos + +EXPOSE 8080 +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--threads", "4", \ + "--timeout", "60", "--access-logfile", "-", "serve_container:app"] diff --git a/compose/hf-pdocker-02/lifeos/docker-compose.yml b/compose/hf-pdocker-02/lifeos/docker-compose.yml new file mode 100644 index 0000000..f4f31e0 --- /dev/null +++ b/compose/hf-pdocker-02/lifeos/docker-compose.yml @@ -0,0 +1,32 @@ +services: + lifeos: + build: + context: /opt/lifeos + dockerfile: Dockerfile + image: lifeos:local + container_name: lifeos + restart: unless-stopped + ports: + # 8080 on this node is taken by unifi-os-server. + - "8095:8080" + environment: + - TZ=America/Chicago + # AI companion -> Ollama on the GPU machine (OpenAI-compatible endpoint). + - AI_BRIDGE_URL=http://192.168.86.150:11434 + - AI_MODEL=gemma4:latest + # Ollama needs ~46s to cold-load gemma4 (0.7s once warm); upstream's + # hardcoded 30s timeout would fail every first message. + - AI_TIMEOUT=120 + volumes: + # SQLite (WAL) stays on local disk: NFS v3 locking breaks it, same reason + # guacamole-db lives on /var/lib. + - /var/lib/lifeos:/data/.lifeos + labels: + - "com.centurylinklabs.watchtower.enable=false" + mem_limit: 1g + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/health-check').read()"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s diff --git a/compose/hf-pdocker-02/lifeos/serve_container.py b/compose/hf-pdocker-02/lifeos/serve_container.py new file mode 100644 index 0000000..af1ef34 --- /dev/null +++ b/compose/hf-pdocker-02/lifeos/serve_container.py @@ -0,0 +1,28 @@ +"""Container entrypoint: serve the Flask API and the built SPA from one process. + +Upstream's backend/serve.py does this too, but it also shells out to `npm run +build` and tries to launch a browser, neither of which works in a container. +""" + +from pathlib import Path + +from flask import send_file, send_from_directory + +from app import app # importing app runs init_db(), creating the SQLite schema + +DIST = Path(__file__).parent / "dist" + + +@app.route("/") +def _index(): + return send_file(DIST / "index.html") + + +@app.route("/") +def _static(path): + asset = DIST / path + if asset.is_file(): + return send_from_directory(DIST, path) + if path.startswith("api/"): + return {"error": "Not found"}, 404 + return send_file(DIST / "index.html") # SPA client-side routing