From 922dbde829fb36e826738fe3b22a0fe9878ed6e2 Mon Sep 17 00:00:00 2001 From: Ammon Knaupp Date: Thu, 2 Apr 2026 14:09:54 +0000 Subject: [PATCH 1/3] Add ssh-tunnel service --- Dockerfile.ssh-tunnel | 4 ++ Makefile | 110 +++++++++++++++++++++++++++++++++++++++--- docker-compose.yml | 17 ++++++- 3 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 Dockerfile.ssh-tunnel diff --git a/Dockerfile.ssh-tunnel b/Dockerfile.ssh-tunnel new file mode 100644 index 0000000..00f766e --- /dev/null +++ b/Dockerfile.ssh-tunnel @@ -0,0 +1,4 @@ +FROM alpine:3.20 + +RUN apk add --no-cache autossh openssh-client sshpass +CMD ["tail", "-f", "/dev/null"] diff --git a/Makefile b/Makefile index d3ab19c..5596658 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ COMPOSE = podman compose PROJECT_NAME ?= dry-lab-notebook-app BASE = -p $(PROJECT_NAME) -f docker-compose.yml +COMPOSE_CMD = $(COMPOSE) $(BASE) PYTHON ?= /app/.venv/bin/python -include .env @@ -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_USER ?= tunnel +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,102 @@ 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}" ] && pid=$$(cat "$${pidfile}") && 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 + @read -r -p "SSH username [$(SSH_TUNNEL_USER)]: " SSH_USER; \ + SSH_USER=$${SSH_USER:-$(SSH_TUNNEL_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)}; \ + printf "SSH password: "; stty -echo; read -r SSH_PASS; stty echo; printf "\n"; \ + if [ -z "$$SSH_PASS" ]; then echo "ERROR: SSH password is required."; exit 1; fi; \ + echo "Starting autossh in background..."; \ + $(COMPOSE_CMD) exec \ + -e SSHPASS="$$SSH_PASS" \ + -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 'nohup sshpass -e 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}" >/tmp/autossh.log 2>&1 & echo $$! >/tmp/autossh.pid'; \ + sleep 1; \ + if $(COMPOSE_CMD) exec ssh-tunnel sh -lc 'pidfile=/tmp/autossh.pid; [ -f "$${pidfile}" ] && pid=$$(cat "$${pidfile}") && kill -0 "$${pid}"' >/dev/null 2>&1; then \ + echo "autossh started. PID: $$($(COMPOSE_CMD) exec ssh-tunnel sh -lc 'cat /tmp/autossh.pid')"; \ + echo "Check logs with: make tunnel-status"; \ + else \ + echo "ERROR: autossh failed to start. Recent log output:"; \ + $(COMPOSE_CMD) exec ssh-tunnel sh -lc "tail -n 80 /tmp/autossh.log || true"; \ + exit 1; \ + fi + +tunnel-status: tunnel-helper-up + @if $(COMPOSE_CMD) exec ssh-tunnel sh -lc 'pidfile=/tmp/autossh.pid; [ -f "$${pidfile}" ] && pid=$$(cat "$${pidfile}") && 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}" ] && pid=$$(cat "$${pidfile}") && 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 diff --git a/docker-compose.yml b/docker-compose.yml index 118dff9..637ee46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,13 +36,28 @@ services: nginx: image: nginx:1.27-alpine ports: - - "${WEBPORT}:80" + - "${NGINX_PORT:-8080}:80" volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - staticfiles_data:/staticfiles:ro depends_on: - web + ssh-tunnel: + build: + context: . + dockerfile: Dockerfile.ssh-tunnel + entrypoint: ["tail"] + command: ["-f", "/dev/null"] + environment: + - SSH_TUNNEL_HOST=${SSH_TUNNEL_HOST} + - SSH_TUNNEL_SSH_PORT=${SSH_TUNNEL_SSH_PORT:-22} + - SSH_TUNNEL_USER=${SSH_TUNNEL_USER:-tunnel} + - STOMP_TUNNEL_REMOTE_PORT=${STOMP_TUNNEL_REMOTE_PORT:-61613} + depends_on: + - rabbitmq + restart: unless-stopped + volumes: rabbitmq_data: staticfiles_data: \ No newline at end of file From 0ab29ffcb965796c3b314b6b5bd9cf5c9f3e41b0 Mon Sep 17 00:00:00 2001 From: Ammon Knaupp Date: Thu, 2 Apr 2026 15:13:38 -0400 Subject: [PATCH 2/3] Simplify dev environment to only Django project and move Nginx into django container --- .devcontainer/devcontainer.json | 35 ++++++++-------- .devcontainer/docker-compose.yml | 47 ---------------------- .env.example | 3 +- Dockerfile | 9 ++++- README.md | 31 ++++---------- config/settings.py | 1 + docker-compose.yml | 16 ++------ dry_lab_notebook/templates/stomp-logs.html | 12 +++--- dry_lab_notebook/views.py | 1 + nginx/default.conf | 6 ++- scripts/start-web.sh | 8 ++++ supervisor/web.conf | 27 +++++++++++++ 12 files changed, 88 insertions(+), 108 deletions(-) delete mode 100644 .devcontainer/docker-compose.yml create mode 100644 scripts/start-web.sh create mode 100644 supervisor/web.conf 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..2e9945b 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,7 @@ 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= 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/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 637ee46..a9830af 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,7 @@ 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" - - nginx: - image: nginx:1.27-alpine - ports: - - "${NGINX_PORT:-8080}:80" - volumes: - - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - - staticfiles_data:/staticfiles:ro - depends_on: - - web + command: ["/app/scripts/start-web.sh"] ssh-tunnel: build: @@ -52,7 +44,7 @@ services: environment: - SSH_TUNNEL_HOST=${SSH_TUNNEL_HOST} - SSH_TUNNEL_SSH_PORT=${SSH_TUNNEL_SSH_PORT:-22} - - SSH_TUNNEL_USER=${SSH_TUNNEL_USER:-tunnel} + - SSH_TUNNEL_USER=${SSH_TUNNEL_USER} - STOMP_TUNNEL_REMOTE_PORT=${STOMP_TUNNEL_REMOTE_PORT:-61613} depends_on: - rabbitmq diff --git a/dry_lab_notebook/templates/stomp-logs.html b/dry_lab_notebook/templates/stomp-logs.html index f2db6a0..f0a2662 100644 --- a/dry_lab_notebook/templates/stomp-logs.html +++ b/dry_lab_notebook/templates/stomp-logs.html @@ -100,6 +100,7 @@

Snakemake logs