Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions Dockerfile_uwsgi
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
FROM python:3.11
FROM python:3.11.13-bookworm

# Metadata
LABEL Name="SynAnno" Version="1.0.0"

# Set working directory
WORKDIR /app

# Upgrade pip
RUN python -m pip install --no-cache-dir --upgrade pip
# Pin pip to the version in the previous deployed image
RUN python -m pip install --no-cache-dir pip==25.2

# Install system dependencies for PyQt5
RUN apt-get update && apt-get install -y \
Expand All @@ -26,16 +26,20 @@ ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo --version

# Copy application code
COPY setup.py /app/
COPY setup.py requirements.lock /app/
COPY synanno /app/synanno
COPY run_production.py /app/
COPY h01/h01_104_materialization.csv /app/h01/h01_104_materialization.csv

# Install Python dependencies
RUN python -m pip install uv
RUN uv pip install --system --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
RUN uv pip install --system --no-cache-dir torchsummary tqdm -e .
RUN python -m pip install --no-cache-dir uwsgi
# Install the locked Python dependencies, then the app. torch/torchvision come only
# from the CPU-only PyTorch index; everything else comes only from PyPI.
RUN grep -E '^(torch|torchvision)==' requirements.lock > /tmp/torch.lock \
&& grep -vE '^(torch|torchvision)==' requirements.lock > /tmp/pypi.lock \
&& python -m pip install --no-cache-dir --no-deps -r /tmp/torch.lock \
--index-url https://download.pytorch.org/whl/cpu \
&& python -m pip install --no-cache-dir --no-deps -r /tmp/pypi.lock \
&& rm /tmp/torch.lock /tmp/pypi.lock
RUN python -m pip install --no-cache-dir --no-deps -e .

# Copy uWSGI configuration
COPY uwsgi.ini /app
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ SynAnno is a tool designed for interactive guided proofreading and correction of

## Live Demo

A simplified version of the app, starting directly in the Error Categorization view, is available [here](http://54.210.88.222/demo).
A simplified version of the app, starting directly in the Error Categorization view, is available [here](http://54.164.194.68/demo).

For the full functionality and configuration options of SynAnno, try the complete version [here](http://54.210.88.222/reset).
For the full functionality and configuration options of SynAnno, try the complete version [here](http://54.164.194.68/reset).

---

Expand Down
90 changes: 90 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Deploying the public demo

The public demo (linked from the README) runs as a single ECS task on one Graviton
(arm64) EC2 instance in AWS `us-east-1`. The concrete account and resource IDs are kept
out of this public repository; ask a maintainer for them. Placeholders below look like
`<account-id>`.

## How it is deployed

- **Image:** `<account-id>.dkr.ecr.us-east-1.amazonaws.com/synanno/uwsgi:<git sha>`,
built from `Dockerfile_uwsgi` for `linux/arm64`. Dependencies are pinned in
`requirements.lock`; change a pin there, not in `setup.py`.
- **Task definition:** one container, network mode `host`, port 80 (Flask/uWSGI) and
port 9015 (Neuroglancer), with the environment:
- `PUBLIC_DNS_SYNANNO=<elastic-ip>`: the embedded Neuroglancer view is loaded from
`http://<PUBLIC_DNS_SYNANNO>:9015/v/<token>/`, so this must be the public address.
- `SYNANNO_PUBLIC_DEMO=1`: limits the app to the bundled H01 data and refuses
credential uploads.
- `SECRET_KEY` is not set; the app generates one at startup.
- **Address:** an Elastic IP associated with the instance.
- **Firewall:** the instance's security group allows only ports 80 and 9015.
- **Logs:** CloudWatch, in the service's log group.

The demo keeps one shared state for all visitors, and `/reset` and `/demo` wipe it.
That is intended: the demo is a single-user test deployment.

## Release a new version

Pass `--region us-east-1` on every AWS command.

```bash
REPO=<account-id>.dkr.ecr.us-east-1.amazonaws.com/synanno/uwsgi
SHA=$(git rev-parse --short HEAD)
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin "${REPO%%/*}"
docker build --platform linux/arm64 -f Dockerfile_uwsgi -t "$REPO:$SHA" .
docker push "$REPO:$SHA"
```

Register a new task definition revision that differs from the current one only in the
image tag, then switch the service. Host networking with fixed ports cannot run two
copies on one instance, so the service stops the old task before starting the new one
(minimum healthy 0%, maximum 100%; Availability Zone rebalancing is disabled because
it requires a maximum above 100%). Expect a short downtime.

```bash
aws ecs update-service --region us-east-1 --cluster <cluster> --service <service> \
--task-definition <family>:<new revision> --force-new-deployment
aws ecs wait services-stable --region us-east-1 --cluster <cluster> --services <service>
scripts/smoke_test.sh http://<elastic-ip>
```

Once the smoke test passes, point the `latest` tag at the new image as well.

## Roll back

Switch the service back to an earlier task definition revision with the same
`update-service` command, then run the smoke test. Rollback revisions should reference
their image **by digest** (`…/synanno/uwsgi@sha256:…`), not by a tag such as `latest`,
which moves with every release.

## If the Auto Scaling group replaces the instance

A replacement instance gets a new auto-assigned public IP, and the Elastic IP is not
moved to it. Re-associate it, then run the smoke test:

```bash
NEW_ID=$(aws ec2 describe-instances --region us-east-1 \
--filters Name=tag:aws:autoscaling:groupName,Values=<asg-name> \
Name=instance-state-name,Values=running \
--query 'Reservations[0].Instances[0].InstanceId' --output text)
aws ec2 associate-address --region us-east-1 \
--allocation-id <eip-allocation-id> --instance-id "$NEW_ID"
```

The task definition does not need to change: it names the Elastic IP, not the instance.

## Shell access (SSH)

Port 22 is closed, and nothing in the deployment needs it. If you need a shell on the
instance, open port 22 for your own IP only, and close it again afterwards:

```bash
MY_IP=$(curl -s https://checkip.amazonaws.com)
aws ec2 authorize-security-group-ingress --region us-east-1 \
--group-id <security-group-id> --protocol tcp --port 22 --cidr "$MY_IP/32"
ssh -i <key-file> ec2-user@<elastic-ip>
aws ec2 revoke-security-group-ingress --region us-east-1 \
--group-id <security-group-id> --protocol tcp --port 22 --cidr "$MY_IP/32"
```
144 changes: 144 additions & 0 deletions requirements.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Exact package set of the deployed image (synanno/uwsgi:rollback-2026-10,
# sha256:b8793d31...), captured with pip freeze on Python 3.11.13 (arm64).
# The app itself is installed separately (pip install --no-deps -e .).
# torch and torchvision come from https://download.pytorch.org/whl/cpu (see Dockerfile_uwsgi).
# flask-cors was dropped together with CORS support; tornado was raised from
# 6.5.1 to 6.5.10 for HTTP-parsing DoS fixes (it serves the public NG port).
atomicwrites==1.4.1
attrs==25.3.0
blinker==1.9.0
boto3==1.40.2
botocore==1.40.2
Brotli==1.1.0
cachelib==0.13.0
cachetools==5.5.2
certifi==2025.8.3
chardet==5.2.0
charset-normalizer==3.4.2
click==8.2.1
cloud-files==5.6.0
cloud_volume==12.4.1
compressed_segmentation==2.3.2
contourpy==1.3.3
crc32c==2.7.1
cycler==0.12.1
deflate==0.8.1
dill==0.4.0
DracoPy==1.5.0
fasteners==0.19
fastremap==1.17.2
filelock==3.13.1
Flask==3.1.1
Flask-Session==0.8.0
flexcache==0.3
flexparser==0.4
fonttools==4.59.0
fpzip==1.2.4
fsspec==2024.6.1
fuzzywuzzy==0.18.0
gevent==25.5.1
google-api-core==2.25.1
google-apitools==0.5.32
google-auth==2.40.3
google-cloud-core==2.4.3
google-cloud-storage==3.2.0
google-crc32c==1.7.1
google-resumable-media==2.7.2
googleapis-common-protos==1.70.0
greenlet==3.2.3
h5py==3.14.0
httplib2==0.22.0
idna==3.10
igraph==0.11.9
imageio==2.37.0
inflection==0.5.1
iniconfig==2.1.0
intervaltree==3.1.0
itsdangerous==2.2.0
Jinja2==3.1.4
jmespath==1.0.1
joblib==1.5.1
json5==0.12.0
jsonschema==4.25.0
jsonschema-specifications==2025.4.1
kiwisolver==1.4.8
lazy_loader==0.4
Markdown==3.8.2
MarkupSafe==2.1.5
matplotlib==3.10.5
microviewer==1.17.1
molesq==0.4.0
morphops==0.1.13
mpmath==1.3.0
msgspec==0.19.0
multiprocess==0.70.18
navis==1.10.0
ncollpyde==0.19.0
networkx==3.1
neuroglancer==2.40.1
numpy==1.26.4
oauth2client==4.1.3
opencv-python==4.11.0.86
orjson==3.11.1
osteoid==0.3.2
packaging==25.0
pandas==2.3.1
pathos==0.3.4
pillow==11.0.0
Pint==0.24.4
platformdirs==4.3.8
pluggy==1.6.0
posix_ipc==1.3.0
pox==0.3.6
ppft==1.7.7
proto-plus==1.26.1
protobuf==6.31.1
psutil==7.0.0
pyasn1==0.6.1
pyasn1_modules==0.4.2
Pygments==2.19.2
pykdtree==1.4.2
pynrrd==1.1.3
pyparsing==3.2.3
pypng==0.20220715.0
pysimdjson==7.0.2
pytest==8.4.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
python-jsonschema-objects==0.5.7
pytz==2025.2
rdata==0.11.2
referencing==0.36.2
requests==2.32.4
rpds-py==0.26.0
rsa==4.9.1
s3transfer==0.13.1
scikit-image==0.25.2
scikit-learn==1.7.1
scipy==1.16.1
seaborn==0.13.2
simplejpeg==1.8.2
six==1.17.0
skeletor==1.3.0
sortedcontainers==2.4.0
sympy==1.13.3
tenacity==9.1.2
texttable==1.7.0
threadpoolctl==3.6.0
tifffile==2025.6.11
torch==2.7.1+cpu
torchsummary==1.5.1
torchvision==0.22.1
tornado==6.5.10
tqdm==4.67.1
trimesh==4.7.1
typing_extensions==4.12.2
tzdata==2025.2
urllib3==2.5.0
uv==0.8.4
uWSGI==2.0.30
Werkzeug==3.1.3
xarray==2025.7.1
zope.event==5.1
zope.interface==7.2
zstandard==0.23.0
8 changes: 7 additions & 1 deletion run.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import os

from dotenv import load_dotenv

Expand All @@ -14,4 +15,9 @@


if __name__ == "__main__":
app.run(host=app.config["IP"], port=app.config["PORT"], debug=True)
# The dev server binds to localhost and runs without the debugger unless asked
app.run(
host=os.getenv("APP_IP", "127.0.0.1"),
port=app.config["PORT"],
debug=os.getenv("FLASK_DEBUG", "0") == "1",
)
77 changes: 77 additions & 0 deletions scripts/smoke_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Smoke test for a deployed SynAnno demo (SYNANNO_PUBLIC_DEMO=1).
#
# Usage: scripts/smoke_test.sh http://<host>
#
# Calls /reset and /demo, which wipe the shared demo state (by design: the demo
# is single-user). Makes no other state changes.

set -u

BASE="${1:?usage: $0 http://<host>}"
BASE="${BASE%/}"
H01_SOURCE="gs://h01-release/data/20210601/4nm_raw"
H01_TARGET="gs://h01-release/data/20210729/c3/synapses/whole_ei_onlyvol"
H01_NEUROPIL="gs://h01-release/data/20210601/proofread_104"

failures=0
pass() { echo "PASS $1"; }
fail() { echo "FAIL $1"; failures=$((failures + 1)); }

status() { curl -s -o /dev/null -m "${2:-30}" -w "%{http_code}" "${@:3}" "$1"; }

# 1. Pages load
for path in / /reset /demo; do
code=$(status "$BASE$path")
[[ $code == 200 ]] && pass "GET $path -> $code" || fail "GET $path -> $code"
done

# 2. The demo loads, and its Neuroglancer view is served from <host>:9015 with an
# unguessable token (the same /neuro call the annotation page makes)
code=$(status "$BASE/demo_annotation" 600)
[[ $code == 200 ]] && pass "GET /demo_annotation -> $code" || fail "GET /demo_annotation -> $code"
ng_json=$(curl -s -m 120 -X POST "$BASE/neuro" -d mode=draw -d cz0=0 -d cy0=0 -d cx0=0)
ng_url=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["ng_link"])' "$ng_json" 2>/dev/null)
host="${BASE#*://}"
if [[ $ng_url =~ ^http://${host}:9015/v/([A-Za-z0-9_-]{40,})/$ ]]; then
pass "Neuroglancer URL $ng_url"
code=$(status "$ng_url")
[[ $code == 200 ]] && pass "GET Neuroglancer view -> $code" \
|| fail "GET Neuroglancer view -> $code"
else
fail "Neuroglancer URL not on $host:9015 with a long token: '${ng_url:-$ng_json}'"
fi

# 3. /load_materialization rejects local files and external URLs
for url in /etc/passwd file:///etc/passwd http://example.com/table.csv; do
code=$(status "$BASE/load_materialization" 30 -X POST \
-H "Content-Type: application/json" \
-d "{\"materialization_url\": \"$url\"}")
[[ $code =~ ^4 ]] && pass "load_materialization $url -> $code" \
|| fail "load_materialization $url -> $code"
done

# 4. No wildcard CORS header
if curl -s -o /dev/null -D - -m 30 -H "Origin: http://evil.example" "$BASE/" \
| grep -qi "^access-control-allow-origin"; then
fail "Access-Control-Allow-Origin header sent"
else
pass "No Access-Control-Allow-Origin header"
fi

# 5. Credential upload is disabled (rejected before any state change)
creds=$(mktemp)
echo '{"token": "smoke-test-not-a-secret"}' > "$creds"
code=$(status "$BASE/upload" 30 -X POST \
-F "source_url=$H01_SOURCE" -F "target_url=$H01_TARGET" \
-F "neuropil_url=$H01_NEUROPIL" -F "view_style=neuron" \
-F "tiles_per_page=12" -F "secrets_file=@$creds;type=application/json")
rm -f "$creds"
[[ $code == 403 ]] && pass "Credential upload -> $code" || fail "Credential upload -> $code"

echo
if ((failures)); then
echo "$failures check(s) failed"
exit 1
fi
echo "All checks passed"
1 change: 0 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
install_requires=[
"numpy<2",
"flask>=2.3.2",
"flask-cors>=4.0.0",
"flask-session>=0.5.0",
"cloud-volume",
"navis==1.10.0",
Expand Down
Loading
Loading