From 690de21fa4fc00f0fc1e5c344f57aaa093ff86fb Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Mon, 28 Sep 2026 10:45:07 -0400 Subject: [PATCH] Add Fly release image and local start command --- .dockerignore | 16 +++++++++++++ Dockerfile | 43 +++++++++++++++++++++++++++++++++ README.md | 3 +-- do | 59 ++++++++++++++++++++++++++++++++++++++++++++++ fly.toml | 38 +++++++++++++++++++++++++++++ requirements.txt | 2 ++ server/settings.py | 22 +++++++++++++---- 7 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100755 do create mode 100644 fly.toml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ef0e9d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.venv +**/node_modules +frontend/.shadow-cljs +frontend/out +frontend/.cpcache +frontend/test/browser/out +static/arthur/js +db.sqlite3 +var +frames +scratch +audio.wav +manifest.json +*.take +*.tflite diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..805d9a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +FROM node:20-bookworm AS frontend + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl tar \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL 'https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk/hotspot/normal/eclipse' -o /tmp/jdk.tar.gz \ + && mkdir -p /opt/java \ + && tar -xzf /tmp/jdk.tar.gz -C /opt/java --strip-components=1 \ + && rm /tmp/jdk.tar.gz + +ENV JAVA_HOME=/opt/java +ENV PATH="/opt/java/bin:${PATH}" +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npx shadow-cljs release app + +FROM python:3.12-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DJANGO_DEBUG=0 \ + DJANGO_DB_PATH=/data/db.sqlite3 \ + DJANGO_BLOB_ROOT=/data/blobs + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --shell /usr/sbin/nologin app + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY . ./ +COPY --from=frontend /app/static/arthur/js/ ./static/arthur/js/ +RUN python manage.py collectstatic --noinput \ + && mkdir -p /data \ + && chown -R app:app /app /data + +USER app +EXPOSE 8000 +CMD ["sh", "-c", "python manage.py migrate --noinput && exec gunicorn server.wsgi:application --bind 0.0.0.0:8000 --workers 1 --threads 4 --timeout 120"] diff --git a/README.md b/README.md index 01ce7dd..d374cc7 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,7 @@ gaps; its controls are still pending. mise install # both halves pip install -r requirements.txt mise exec -- python manage.py migrate -mise exec -- python manage.py runserver 8778 # then open localhost:8778 -cd frontend && mise exec -- npx shadow-cljs watch app +./do start # Django + frontend watcher ``` See [frontend/README.md](frontend/README.md) for the **load frames** and **save** diff --git a/do b/do new file mode 100755 index 0000000..30f2ae6 --- /dev/null +++ b/do @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Run the local app and its frontend watcher with one command.""" + +import os +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent + + +def start(): + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", 8778)) == 0: + raise SystemExit("port 8778 is already in use") + + commands = [ + (["mise", "exec", "--", "python", "manage.py", "runserver", "8778"], ROOT), + (["mise", "exec", "--", "npx", "shadow-cljs", "watch", "app"], ROOT / "frontend"), + ] + children = [] + stopping = False + + def stop(_signal, _frame): + nonlocal stopping + stopping = True + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + try: + for command, directory in commands: + children.append(subprocess.Popen(command, cwd=directory, start_new_session=True)) + print("arthur: http://localhost:8778 (Ctrl-C stops both processes)", flush=True) + while not stopping: + for child in children: + if child.poll() is not None: + raise SystemExit(f"app process exited with status {child.returncode}") + time.sleep(0.25) + finally: + for child in children: + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + for child in children: + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.wait() + + +if __name__ == "__main__": + if sys.argv[1:] == ["start"]: + start() + else: + raise SystemExit("usage: ./do start") diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..a6a07dc --- /dev/null +++ b/fly.toml @@ -0,0 +1,38 @@ +app = "arthur" +primary_region = "iad" + +[build] + dockerfile = "Dockerfile" + +[env] + DJANGO_DEBUG = "0" + DJANGO_ALLOWED_HOSTS = ".fly.dev" + DJANGO_CSRF_TRUSTED = "https://arthur.fly.dev" + DJANGO_DB_PATH = "/data/db.sqlite3" + DJANGO_BLOB_ROOT = "/data/blobs" + +[[mounts]] + source = "data" + destination = "/data" + initial_size = "1gb" + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 1 + processes = ["app"] + + [[http_service.checks]] + interval = "30s" + timeout = "5s" + grace_period = "60s" + method = "GET" + path = "/" + headers = { Host = "arthur.fly.dev" } + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory = "1gb" diff --git a/requirements.txt b/requirements.txt index 5bc4225..f15dc94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ # # Channels arrives with the websocket consumers, which are out of step 9's scope. Django>=5.0,<6.0 +gunicorn>=23.0,<24.0 +whitenoise>=6.9,<7.0 diff --git a/server/settings.py b/server/settings.py index 5cf59ac..3aced90 100644 --- a/server/settings.py +++ b/server/settings.py @@ -14,15 +14,22 @@ of what there is to know about this file: `var/blobs`. Never in the database, and never in a migration. """ from pathlib import Path +import os BASE_DIR = Path(__file__).resolve().parent.parent # Dev default. The deployment that needs a real one will set it, and until there # is a deployment, a checked-in placeholder that says so beats a checked-in secret # that does not. -SECRET_KEY = "dev-only-not-a-secret-arthur" -DEBUG = True -ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"] +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-not-a-secret-arthur") +DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes"} +ALLOWED_HOSTS = os.environ.get( + "DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,[::1]" +).split(",") +CSRF_TRUSTED_ORIGINS = [ + origin for origin in os.environ.get("DJANGO_CSRF_TRUSTED", "").split(",") + if origin +] INSTALLED_APPS = [ "django.contrib.admin", @@ -36,6 +43,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -77,7 +85,7 @@ TEMPLATES = [ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "NAME": os.environ.get("DJANGO_DB_PATH", str(BASE_DIR / "db.sqlite3")), "OPTIONS": { "timeout": 20, "init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;", @@ -107,6 +115,10 @@ STATICFILES_DIRS = [ ("mediapipe", BASE_DIR / "frontend" / "public" / "mediapipe"), ] STATIC_ROOT = BASE_DIR / "var" / "static" +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage"}, +} # --- blobs ------------------------------------------------------------------ # @@ -115,7 +127,7 @@ STATIC_ROOT = BASE_DIR / "var" / "static" # `static/`: a blob is served by a view that can set immutable cache headers and # refuse a path that is not a hash, and `collectstatic` has no business walking # gigabytes of frames. -BLOB_ROOT = BASE_DIR / "var" / "blobs" +BLOB_ROOT = Path(os.environ.get("DJANGO_BLOB_ROOT", str(BASE_DIR / "var" / "blobs"))) # The port the browser suite expects by default, and not 8777, which is still the # old JS tool's under `serve.py`. Both are meant to run side by side.