diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a7a95dd..3f443ec 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,28 +1,31 @@ { "name": "dry-lab-notebook", - "dockerComposeFile": [ - "docker-compose.yml" - ], - "service": "web", - "runServices": [ - "web", - "rabbitmq" - ], + "build": { + "dockerfile": "../Dockerfile", + "context": ".." + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/app,type=bind", "workspaceFolder": "/app", - "shutdownAction": "stopCompose", "overrideCommand": true, + "runArgs": [ + "--add-host=host.docker.internal:host-gateway", + "--name=dry-lab-notebook-devcontainer" + ], + "containerEnv": { + "DEBUG": "1", + "RABBITMQ_URL": "stomp://drylabnotebook:guest@host.docker.internal:61613", + "RABBITMQ_DEFAULT_USER": "drylabnotebook", + "RABBITMQ_DEFAULT_PASS": "guest", + "STOMP_BROWSER_WS_URL": "ws://localhost:15674/ws", + "PROJECT_TITLE": "Dry Lab Notebook (dev configuration)" + }, "forwardPorts": [ - 8000, - 15682 + 8000 ], "portsAttributes": { "8000": { "label": "Django dev server", "onAutoForward": "notify" - }, - "15682": { - "label": "RabbitMQ management (dev)", - "onAutoForward": "silent" } }, "customizations": { @@ -44,4 +47,4 @@ "source=${localWorkspaceFolderBasename}-venv,target=/app/.venv,type=volume" ], "postCreateCommand": "python -m venv .venv && .venv/bin/pip install -r requirements.txt" -} \ No newline at end of file +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml deleted file mode 100644 index 15353ce..0000000 --- a/.devcontainer/docker-compose.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: dry-lab-notebook-dev - -services: - rabbitmq: - image: rabbitmq:3-management - ports: - - "15670:15670" # web STOMP examples UI - - "15682:15672" # main UI (shifted for dev) - - "15684:15674" # web STOMP (web sockets, shifted for dev) - - "15692:15692" # prometheus - - "61614:61613" # stomp (shifted for dev) - environment: - - RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:-guest} - - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:-guest} - volumes: - - rabbitmq_data:/var/lib/rabbitmq - - ../rabbitmq_enabled_plugins:/etc/rabbitmq/enabled_plugins - - ../rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro - - ../rabbitmq-definitions.json:/etc/rabbitmq/definitions.json:ro - - web: - build: - context: .. - container_name: dry-lab-notebook-devcontainer - ports: - - "8000:8000" - volumes: - - ..:/app - environment: - - DEBUG=1 - - RABBITMQ_URL=stomp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq:61613 - env_file: - - ../.env - depends_on: - - rabbitmq - mem_limit: 4G - deploy: - resources: - limits: - cpus: '2.0' # Max 2 cores - memory: 4G # Max 4GB RAM - reservations: - cpus: '0.5' # Guaranteed 0.5 cores - memory: 1G # Guaranteed 1GB RAM - -volumes: - rabbitmq_data: \ No newline at end of file diff --git a/.env.example b/.env.example index 2900e38..941d5c8 100644 --- a/.env.example +++ b/.env.example @@ -7,12 +7,12 @@ CLIENT_SECRET= SOCIAL_AUTH_GLOBUS_KEY= SOCIAL_AUTH_GLOBUS_SECRET= LOCAL_FS_BASE= -WEBPORT= -PROJECT_TITLE="Dry Lab Notebook" +NGINX_PORT= SECRET_KEY= ALLOWED_HOST= STATICFILES_HOST_DIR= RABBITMQ_USER=drylabnotebook +STOMP_PRODUCER_HOST= INDEX_1_SLUG= INDEX_1_NAME= diff --git a/Dockerfile b/Dockerfile index 078d00f..0ba2e88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,8 @@ RUN apt-get update && apt-get install -y \ git \ make \ jq \ + nginx \ + supervisor \ && rm -rf /var/lib/apt/lists/* # Install Python dependencies @@ -22,4 +24,9 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy project -COPY . . \ No newline at end of file +COPY . . + +RUN chmod +x /app/scripts/start-web.sh \ + && rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf \ + && cp /app/nginx/default.conf /etc/nginx/conf.d/default.conf \ + && cp /app/supervisor/web.conf /etc/supervisor/conf.d/web.conf \ No newline at end of file diff --git a/Dockerfile.ssh-tunnel b/Dockerfile.ssh-tunnel new file mode 100644 index 0000000..2699132 --- /dev/null +++ b/Dockerfile.ssh-tunnel @@ -0,0 +1,7 @@ +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends autossh openssh-client sshpass tini \ + && rm -rf /var/lib/apt/lists/* + +CMD ["tail", "-f", "/dev/null"] diff --git a/Makefile b/Makefile index d3ab19c..961bab6 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,17 @@ COMPOSE = podman compose PROJECT_NAME ?= dry-lab-notebook-app BASE = -p $(PROJECT_NAME) -f docker-compose.yml -PYTHON ?= /app/.venv/bin/python +COMPOSE_CMD = $(COMPOSE) $(BASE) +PYTHON ?= .venv/bin/python -include .env export STATICFILES_HOST_DIR ?= $(PWD)/staticfiles -STOMP_HOST ?= rabbitmq +STOMP_HOST ?= localhost STOMP_PORT ?= 61613 STOMP_VHOST ?= / -STOMP_STREAM_QUEUE ?= /queue/test +STOMP_STREAM_QUEUE ?= /queue/snakemake.events STOMP_USE_STREAM ?= 1 STOMP_STREAM_OFFSET ?= last STOMP_PREFETCH_COUNT ?= 100 @@ -19,8 +20,11 @@ STOMP_IDLE_TIMEOUT_SECONDS ?= 2 STOMP_FOLLOW ?= 0 STOMP_DEBUG ?= WORKFLOW_EVENTS_OUT ?= workflow-events.jsonl +SSH_TUNNEL_SSH_PORT ?= 22 +SSH_TUNNEL_HOST ?= $(STOMP_PRODUCER_HOST) +STOMP_TUNNEL_REMOTE_PORT ?= 61613 -.PHONY: dev-up dev-down dev-logs dev-shell migrate makemigrations createsuperuser test build staticfiles-dir image-check prod-up prod-down collectstatic live-test-setup live-test-snakemake collect-workflow-events +.PHONY: dev-up dev-down dev-logs dev-shell migrate makemigrations createsuperuser test build staticfiles-dir image-check prod-up prod-down collectstatic tunnel-helper-up tunnel-refresh tunnel-up tunnel-connect tunnel-status tunnel-stop live-test-setup live-test-snakemake collect-workflow-events db.sqlite3: @echo "WARNING: db.sqlite3 not found — creating empty file to prevent Docker mount issue." @@ -43,16 +47,16 @@ dev-shell: @echo "Use the integrated terminal inside the devcontainer." migrate: - $(COMPOSE) $(BASE) exec web python manage.py migrate + $(COMPOSE_CMD) exec web python manage.py migrate makemigrations: - $(COMPOSE) $(BASE) exec web python manage.py makemigrations + $(COMPOSE_CMD) exec web python manage.py makemigrations createsuperuser: - $(COMPOSE) $(BASE) exec web python manage.py createsuperuser + $(COMPOSE_CMD) exec web python manage.py createsuperuser test: - $(COMPOSE) $(BASE) exec web python manage.py test + $(COMPOSE_CMD) exec web python manage.py test build: podman build -t dry-lab-notebook . @@ -74,10 +78,94 @@ collectstatic: image-check staticfiles-dir python manage.py collectstatic --noinput prod-up: db.sqlite3 - $(COMPOSE) $(BASE) up -d --build --force-recreate + $(COMPOSE_CMD) up -d --build --force-recreate prod-down: - $(COMPOSE) $(BASE) down + $(COMPOSE_CMD) down + +# Internal helper: ensure helper services are up and ssh-tunnel accepts exec. +tunnel-helper-up: + @test -n "$(SSH_TUNNEL_HOST)" || { echo "ERROR: SSH_TUNNEL_HOST is not set in .env"; exit 1; } + $(COMPOSE_CMD) up -d rabbitmq ssh-tunnel + @ready=0; \ + for i in 1 2 3 4 5 6 7 8 9 10; do \ + if $(COMPOSE_CMD) exec ssh-tunnel true >/dev/null 2>&1; then \ + ready=1; \ + break; \ + fi; \ + sleep 1; \ + done; \ + if [ "$$ready" -ne 1 ]; then \ + echo "ERROR: ssh-tunnel helper did not become ready in time."; \ + $(COMPOSE_CMD) ps rabbitmq ssh-tunnel; \ + exit 1; \ + fi + +tunnel-refresh: + @test -n "$(SSH_TUNNEL_HOST)" || { echo "ERROR: SSH_TUNNEL_HOST is not set in .env"; exit 1; } + $(COMPOSE_CMD) up -d --force-recreate rabbitmq ssh-tunnel + $(MAKE) tunnel-helper-up + +tunnel-up: tunnel-helper-up + $(COMPOSE_CMD) ps rabbitmq ssh-tunnel + +tunnel-connect: tunnel-helper-up + @if $(COMPOSE_CMD) exec ssh-tunnel sh -lc 'pidfile=/tmp/autossh.pid; [ -f "$${pidfile}" ] || exit 1; pid=$$(cat "$${pidfile}" 2>/dev/null) || exit 1; case "$${pid}" in ""|*[!0-9]*) exit 1;; esac; [ -d "/proc/$${pid}" ] || exit 1; state=$$(awk "{print \$$3}" "/proc/$${pid}/stat" 2>/dev/null) || exit 1; [ "$${state}" != "Z" ] || exit 1; tr "\000" " " <"/proc/$${pid}/cmdline" | grep -Eq "(^|/)autossh([[:space:]]|$$)" || exit 1; kill -0 "$${pid}"' >/dev/null 2>&1; then \ + echo "autossh already running; reusing existing process."; \ + echo "PID: $$($(COMPOSE_CMD) exec ssh-tunnel sh -lc 'cat /tmp/autossh.pid')"; \ + exit 0; \ + fi + @if [ -z "$$TMUX" ]; then \ + echo "WARNING: Not running inside tmux. Tmux is only way (for now) to detach after performing interactive ssh auth."; \ + fi + @read -r -p "SSH username: " SSH_USER; \ + if [ -z "$$SSH_USER" ]; then echo "ERROR: SSH username is required."; exit 1; fi; \ + read -r -p "SSH host [$(SSH_TUNNEL_HOST)]: " SSH_HOST; \ + SSH_HOST=$${SSH_HOST:-$(SSH_TUNNEL_HOST)}; \ + if [ -z "$$SSH_HOST" ]; then echo "ERROR: SSH host is required."; exit 1; fi; \ + read -r -p "SSH port [$(SSH_TUNNEL_SSH_PORT)]: " SSH_PORT; \ + SSH_PORT=$${SSH_PORT:-$(SSH_TUNNEL_SSH_PORT)}; \ + echo "Starting autossh. SSH may prompt for credentials once."; \ + $(COMPOSE_CMD) exec \ + -e AUTOSSH_PIDFILE="/tmp/autossh.pid" \ + -e AUTOSSH_LOGFILE="/tmp/autossh.log" \ + -e SSH_TUNNEL_RUNTIME_USER="$$SSH_USER" \ + -e SSH_TUNNEL_RUNTIME_HOST="$$SSH_HOST" \ + -e SSH_TUNNEL_RUNTIME_PORT="$$SSH_PORT" \ + ssh-tunnel sh -lc 'rm -f /tmp/autossh.pid /tmp/autossh.log; autossh -M 0 -N -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ServerAliveInterval=10 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -R 0.0.0.0:$(STOMP_TUNNEL_REMOTE_PORT):rabbitmq:61613 -p "$${SSH_TUNNEL_RUNTIME_PORT}" "$${SSH_TUNNEL_RUNTIME_USER}@$${SSH_TUNNEL_RUNTIME_HOST}"' + +tunnel-status: tunnel-helper-up + @if $(COMPOSE_CMD) exec ssh-tunnel sh -lc 'pidfile=/tmp/autossh.pid; [ -f "$${pidfile}" ] || exit 1; pid=$$(cat "$${pidfile}" 2>/dev/null) || exit 1; case "$${pid}" in ""|*[!0-9]*) exit 1;; esac; [ -d "/proc/$${pid}" ] || exit 1; state=$$(awk "{print \$$3}" "/proc/$${pid}/stat" 2>/dev/null) || exit 1; [ "$${state}" != "Z" ] || exit 1; tr "\000" " " <"/proc/$${pid}/cmdline" | grep -Eq "(^|/)autossh([[:space:]]|$$)" || exit 1; kill -0 "$${pid}"' >/dev/null 2>&1; then \ + echo "autossh running. PID: $$($(COMPOSE_CMD) exec ssh-tunnel sh -lc 'cat /tmp/autossh.pid')"; \ + else \ + echo "autossh is not running."; \ + fi + @echo "Recent autossh log:"; \ + if $(COMPOSE_CMD) exec ssh-tunnel sh -lc "tail -n 40 /tmp/autossh.log" >/dev/null 2>&1; then \ + $(COMPOSE_CMD) exec ssh-tunnel sh -lc "tail -n 40 /tmp/autossh.log"; \ + else \ + echo "ssh-tunnel helper container not running yet or no log file present."; \ + fi + +tunnel-stop: tunnel-helper-up + @if $(COMPOSE_CMD) exec ssh-tunnel sh -lc 'pidfile=/tmp/autossh.pid; [ -f "$${pidfile}" ] || exit 1; pid=$$(cat "$${pidfile}" 2>/dev/null) || exit 1; case "$${pid}" in ""|*[!0-9]*) exit 1;; esac; [ -d "/proc/$${pid}" ] || exit 1; state=$$(awk "{print \$$3}" "/proc/$${pid}/stat" 2>/dev/null) || exit 1; [ "$${state}" != "Z" ] || exit 1; tr "\000" " " <"/proc/$${pid}/cmdline" | grep -Eq "(^|/)autossh([[:space:]]|$$)" || exit 1; kill -0 "$${pid}"' >/dev/null 2>&1; then \ + $(COMPOSE_CMD) exec ssh-tunnel sh -lc "kill $$(cat /tmp/autossh.pid) && rm -f /tmp/autossh.pid"; \ + echo "autossh stopped."; \ + else \ + echo "autossh is not running."; \ + fi + +check: + $(PYTHON) manage.py check + +deploy: + @test -n "$(DEPLOY_PATH)" || { echo "ERROR: DEPLOY_PATH is not set in .env"; exit 1; } + @test -n "$(WEB_IMAGE)" || { echo "ERROR: WEB_IMAGE is not set in .env"; exit 1; } + cd $(DEPLOY_PATH) && \ + git pull && \ + sed -i 's|^WEB_IMAGE=.*|WEB_IMAGE=$(WEB_IMAGE)|' .env && \ + podman compose pull web && \ + podman compose up -d; \ live-test-setup: $(PYTHON) -m pip install -r requirements-live-test.txt @@ -85,7 +173,7 @@ live-test-setup: live-test-snakemake: live-test-setup $(PYTHON) scripts/live_snakemake_stomp_test.py -collect-workflow-events: live-test-setup +collect-workflow-events: $(PYTHON) scripts/collect_workflow_events.py \ --host "$(STOMP_HOST)" \ --port "$(STOMP_PORT)" \ diff --git a/README.md b/README.md index 420f06e..935f599 100644 --- a/README.md +++ b/README.md @@ -3,29 +3,6 @@ Place to find records and results of dry lab activities. ## Deployment -### Container - -A `Dockerfile` has been prepared for serving the `django` app. -Using `docker compose`, two separate containers can be deployed, one for development and one for production. -The main difference is that the dev server is `manage.py runserver` and the prod server is `gunicorn` and `nginx`. -At the time of writing, `nginx` is not included in the container. -See instructions below on how to configure your system for `nginx` integration. - -To make sure the prod container persists, you may need to prevent your host system will not kill your processes when your session ends. -One way to do this is to enable "lingering" for your user with `loginctl enable-linger $USER`. -Then, when you start a container tied to your user, it will not be killed as soon as you log out. - -### Static files for `nginx` - -`gunicorn` doesn't serve static files. -Instead, delegate the task to `nginx` in two steps: -1. Deposit the project's static files using `python manage.py collectstatic`. -The location must be accessible by `nginx`, e.g. under `/var/www/dry-lab-notebook/staticfiles/`. -The `Makefile` has a target for this `collectstatic`. -This target depends on env var `STATICFILE_HOST_DIR` to put the static files in the right place. -If you update any static files, remember to re-collect. -2. Point `nginx` to these files by putting `location /static/ { alias /var/www/dry-lab-notebook/staticfiles/; }` in the appropriate `server` block (remember, order matters!). - ## Configuring Globus One of the attractive features of Dry Lab Notebook is that is provides unauthenicated access to certain Globus resources. @@ -51,3 +28,11 @@ As opposed to authorizing a client to access a specific collection, access to da Each "entry" (a sub-record of a given "subject") in a Search index is associated with a principal (or list of principals) which defines who gets to access that data. Therefore, for your client to be able to see any of your Search data, you must assign each record with a principal URN which includes your client. This could be the principal URN of your client itself, however it is more practical to create a group which has access and use the group's URN. + +## Troubleshooting + +### Container persistence + +To make sure the prod container persists, you may need to prevent your host system will not kill your processes when your session ends. +One way to do this is to enable "lingering" for your user with `loginctl enable-linger $USER`. +Then, when you start a container tied to your user, it will not be killed as soon as you log out. \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index 6e54ba0..553c2a5 100644 --- a/config/settings.py +++ b/config/settings.py @@ -167,6 +167,7 @@ def _load_dotenv(dotenv_path: Path) -> None: SOCIAL_AUTH_GLOBUS_SECRET = os.environ.get("SOCIAL_AUTH_GLOBUS_SECRET") STOMP_STREAM_QUEUE = os.environ.get("STOMP_STREAM_QUEUE", "/queue/snakemake.events") +STOMP_BROWSER_WS_URL = os.environ.get("STOMP_BROWSER_WS_URL") RABBITMQ_DEFAULT_USER = os.environ.get("RABBITMQ_DEFAULT_USER", "guest") RABBITMQ_DEFAULT_PASS = os.environ.get("RABBITMQ_DEFAULT_PASS", "guest") diff --git a/docker-compose.yml b/docker-compose.yml index 118dff9..96eeb01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: web: image: ${WEB_IMAGE:-dry-lab-notebook:latest} build: . + ports: + - "${NGINX_PORT:-8080}:80" volumes: - ./db.sqlite3:/app/db.sqlite3 - staticfiles_data:/app/staticfiles @@ -31,17 +33,21 @@ services: - .env depends_on: - rabbitmq - command: sh -c "python manage.py collectstatic --noinput && python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:8000" + command: ["/app/scripts/start-web.sh"] - nginx: - image: nginx:1.27-alpine - ports: - - "${WEBPORT}:80" - volumes: - - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - - staticfiles_data:/staticfiles:ro + ssh-tunnel: + build: + context: . + dockerfile: Dockerfile.ssh-tunnel + entrypoint: ["/usr/bin/tini", "--", "tail"] + command: ["-f", "/dev/null"] + environment: + - SSH_TUNNEL_HOST=${STOMP_PRODUCER_HOST} + - SSH_TUNNEL_SSH_PORT=${SSH_TUNNEL_SSH_PORT:-22} + - STOMP_TUNNEL_REMOTE_PORT=${STOMP_TUNNEL_REMOTE_PORT:-61613} depends_on: - - web + - rabbitmq + restart: unless-stopped volumes: rabbitmq_data: diff --git a/dry_lab_notebook/templates/stomp-logs.html b/dry_lab_notebook/templates/stomp-logs.html index f2db6a0..01d9aaf 100644 --- a/dry_lab_notebook/templates/stomp-logs.html +++ b/dry_lab_notebook/templates/stomp-logs.html @@ -3,6 +3,19 @@ {%block headextras%} + {% endblock %} {% block breadcrumbs %}{{block.super}}{% endblock %} @@ -28,7 +41,6 @@

Snakemake logs

Recent Workflows
0 -
@@ -40,9 +52,9 @@

Snakemake logs

- - +
@@ -100,6 +112,7 @@

Snakemake logs