Add Fly release image and local start command

This commit is contained in:
Olive Vaughn 2026-09-28 10:45:07 -04:00
parent 7249e73e7c
commit 690de21fa4
7 changed files with 176 additions and 7 deletions

16
.dockerignore Normal file
View file

@ -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

43
Dockerfile Normal file
View file

@ -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"]

View file

@ -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**

59
do Executable file
View file

@ -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")

38
fly.toml Normal file
View file

@ -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"

View file

@ -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

View file

@ -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.