diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..12882f0a --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# ============================================ +# DeepTutor (podman compose) — host-side configuration +# ============================================ +# Copy to .env and edit. `.env` is gitignored. +# +# All values are loopback bindings on the HOST side. The container-side +# ports come from data/user/settings/system.json (backend_port, frontend_port). +# Edit those + `podman compose restart deeptutor` to change the in-container +# ports. This file is for shifting the host-side mapping only. +# +# The API base URL the browser uses is NOT a compose env var. The +# entrypoint reads `DEEPTUTOR_API_BASE_URL` from +# data/user/settings/system.json on every start (preferring the +# in-network `next_public_api_base`, then the external override +# `next_public_api_base_external`, then `http://localhost:${BACKEND_PORT}`), +# and web/proxy.ts rewrites /api/* and /ws/* to it at request time. +# ============================================ + +# Host-side loopback bindings (override if 8001/3782/8090 are taken) +HOST_PORT_BACKEND=8001 +HOST_PORT_FRONTEND=3782 +HOST_PORT_POCKETBASE=8090 + +# Time zone passed into both backend and frontend. +TZ=UTC diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..5d9e8e1d --- /dev/null +++ b/compose.yaml @@ -0,0 +1,202 @@ +# ============================================ +# DeepTutor — Podman Compose (rootless, read-only rootfs) +# ============================================ +# Requires: +# * podman 4.1+ (rootless) +# * a compose provider. On podman 5.x without the native Go-based +# compose plugin, `podman compose` is a thin wrapper around +# `podman-compose` (Python). Both work; the `docker-compose` CLI +# also works if it's installed and pointed at the podman socket. +# This file is `podman-compose` 1.5+ compatible. +# * Verify with: `podman compose version` and `podman info | grep -i rootless` +# +# Bring up (this file lives next to docker-compose.yml, so always pass -f): +# cp .env.example .env # then edit +# podman compose -f compose.yaml up -d +# podman compose -f compose.yaml ps +# podman compose -f compose.yaml logs -f deeptutor +# +# Tip: alias it for the session — alias dc='podman compose -f compose.yaml' +# or export COMPOSE_FILE=compose.yaml so the default `podman compose` +# picks this file up without -f. +# +# Tear down (keeps volumes): +# podman compose -f compose.yaml down +# Wipe data (DESTRUCTIVE): +# podman compose -f compose.yaml down -v +# +# Top-level choices (see git log / PR for full rationale): +# * All services run with `read_only: true`. The `tmpfs:` mounts below are +# the only writable surface inside each container's rootfs. +# * `userns_mode: keep-id` plus the `:U` suffix on every volume mount +# implements rootless-keep-uid: host UID $UID maps to container UID $UID. +# Files you create on the host are visible as your own user inside. +# * Host-side port bindings are LOOPBACK ONLY (127.0.0.1:). Drop the +# `127.0.0.1:` prefix to expose on all interfaces. +# * Two services: `deeptutor` (backend+frontend in one GHCR image, run +# under supervisord) and `pocketbase` (optional auth/storage sidecar). +# The sandbox-runner sidecar from docker-compose.yml is intentionally +# NOT included: the main app falls back to bwrap (Linux, if installed +# in the image) or the restricted subprocess backend controlled by +# the `sandbox_allow_subprocess` setting in system.json. See +# deeptutor/services/sandbox/config.py:build_backend(). +# * The image runs as a non-root `deeptutor` user (UID 1000). The +# entrypoint chowns `/app/data` and drops privileges via `gosu` before +# starting supervisord, so the running process is UID 1000 inside the +# container. Under `userns_mode: keep-id` that maps to the host user. +# * URL knowledge lives in `data/user/settings/system.json` (in-network +# `next_public_api_base` for the typical case, or +# `next_public_api_base_external` for cloud/external). The entrypoint +# reads the JSON on every start and exports `DEEPTUTOR_API_BASE_URL`, +# which `web/proxy.ts` reads to rewrite `/api/*` and `/ws/*` to the +# configured backend at request time. There is no build-time +# placeholder, no runtime `sed -i` on the bundle, and no compose env +# var for the API base. +# * Runtime settings (ports, auth, model catalog, integrations) live in +# data/user/settings/*.json INSIDE the deeptutor-data volume. The +# entrypoint unsets BACKEND_PORT, FRONTEND_PORT, NEXT_PUBLIC_API_BASE, +# NEXT_PUBLIC_API_BASE_EXTERNAL, AUTH_ENABLED, POCKETBASE_URL, etc. +# and re-exports them from the JSONs on every start. So: edit JSONs +# + `podman compose restart deeptutor`, NOT compose env vars. +# ============================================ + +name: deeptutor + +services: + # ---------------------------------------------------------- + # PocketBase — optional auth + storage sidecar + # ---------------------------------------------------------- + # Activated by setting `integrations.pocketbase_url` to + # `http://pocketbase:8090` in data/user/settings/integrations.json. + # Leave blank to run with the SQLite fallback (single-user / invite-only). + pocketbase: + image: ghcr.io/muchobien/pocketbase:latest + container_name: deeptutor-pocketbase + pull_policy: always + restart: unless-stopped + userns_mode: keep-id + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + - /run:size=16m,mode=0755 + - /var/run:size=8m,mode=0755 + ports: + - "127.0.0.1:${HOST_PORT_POCKETBASE:-8090}:8090" + volumes: + # Bind mount a host directory so the host user (UID $UID) owns + # the SQLite DB and pocketbase's public/hooks dirs. With + # userns_mode: keep-id, the process inside is also UID $UID, so + # reads and writes line up. (The pocketbase image's entrypoint + # uses --dir=/pb_data --publicDir=/pb_public --hooksDir=/pb_hooks + # — absolute paths, no /pb/ prefix.) + - ./data/pocketbase:/pb_data:U + - ./data/pocketbase/public:/pb_public:U + - ./data/pocketbase/hooks:/pb_hooks:U + networks: + - deeptutor + healthcheck: + test: + - CMD + - wget + - --quiet + - --tries=1 + - --spider + - http://localhost:8090/api/health + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + + # ---------------------------------------------------------- + # DeepTutor — backend (FastAPI :8001) + frontend (Next.js :3782) + # ---------------------------------------------------------- + # Both run inside the same image, supervised by supervisord under the + # unprivileged `deeptutor` user (UID 1000). The image's entrypoint loads + # runtime settings from data/user/settings/*.json and exports them into + # the supervisord children's environment, so changing ports/auth/providers + # means editing JSONs + `podman compose restart`. + deeptutor: + image: ghcr.io/hkuds/deeptutor:latest + container_name: deeptutor + pull_policy: always + restart: unless-stopped + userns_mode: keep-id + read_only: true + # Writable surfaces inside the RO rootfs. Each tmpfs is scoped to a + # specific need: + # /tmp — general scratch for Python, uvicorn, Node, Next.js + # /run — standard Linux runtime dir + # /var/run — supervisord writes its pidfile here + # /var/log — covers any supervisord child that defaults there + # /root — catches stray $HOME-style writes (image sets + # PYTHONDONTWRITEBYTECODE=1, but be safe) + # /home — same, for any non-root code path + tmpfs: + - /tmp:size=512m,mode=1777 + - /run:size=32m,mode=0755 + - /var/run:size=8m,mode=0755 + - /var/log:size=64m,mode=0755 + - /root:size=16m,mode=0700 + - /home:size=16m,mode=0755 + ports: + - "127.0.0.1:${HOST_PORT_BACKEND:-8001}:8001" + - "127.0.0.1:${HOST_PORT_FRONTEND:-3782}:3782" + volumes: + # Bind mount a host directory so the host user (UID $UID) owns + # the entire data tree. With userns_mode: keep-id the process + # inside is also UID $UID, so writes from the FastAPI backend, + # Next.js, and supervisord all line up. The same path was used + # by the original docker-compose.yml; we keep it for consistency. + # The data tree holds: admin workspace + runtime settings + # (data/user), per-user workspaces (data/users), partners + # (data/partners), accounts/grants/audit (data/system), + # knowledge bases, memory, logs. One tree to back up. + - ./data:/app/data:U + environment: + # Time zone — picked up by Python (time.tzset) and Next.js at boot. + - TZ=${TZ:-UTC} + # Local LLM (LM Studio / Ollama / vLLM) base URLs in + # data/user/settings/model_catalog.json should use + # `http://host.containers.internal:PORT` (podman) — NOT localhost, + # which inside the container is the container's own loopback. + # Uncomment to enable PocketBase auth (also set integrations.json): + # - POCKETBASE_URL=http://pocketbase:8090 + # - POCKETBASE_EXTERNAL_URL=http://localhost:8090 + healthcheck: + test: + - CMD + - curl + - -fsS + - http://localhost:8001/ + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + depends_on: + pocketbase: + condition: service_healthy + networks: + - deeptutor + # Optional hardening — uncomment to taste. The image drops privileges + # to UID 1000 via `gosu` before starting supervisord, so cap_drop ALL + # is safe in the sense that the running process has no caps anyway; + # uncomment only if you also run the sandbox-runner or similar. + # security_opt: + # - no-new-privileges:true + # cap_drop: + # - ALL + # Resource limits. Honored on cgroup v2 hosts; podman warns and skips + # on cgroup v1 or no-cgroup environments. + # pids_limit: 1024 + # mem_limit: 4g + # cpus: 4.0 + +networks: + deeptutor: + driver: bridge + +# Named volumes intentionally not used: with userns_mode: keep-id the +# container process runs as the host user (UID $UID), but podman +# auto-creates named volumes with UID 100000 (userns-mapped root), +# so 755 perms + wrong owner = PermissionError on the first JSON +# write. Bind mounts on a host directory you own work cleanly.