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
35 changes: 19 additions & 16 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -44,4 +47,4 @@
"source=${localWorkspaceFolderBasename}-venv,target=/app/.venv,type=volume"
],
"postCreateCommand": "python -m venv .venv && .venv/bin/pip install -r requirements.txt"
}
}
47 changes: 0 additions & 47 deletions .devcontainer/docker-compose.yml

This file was deleted.

4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ RUN apt-get update && apt-get install -y \
git \
make \
jq \
nginx \
supervisor \
&& rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy project
COPY . .
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
7 changes: 7 additions & 0 deletions Dockerfile.ssh-tunnel
Original file line number Diff line number Diff line change
@@ -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"]
110 changes: 99 additions & 11 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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."
Expand All @@ -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 .
Expand All @@ -74,18 +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}" ] || 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

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)" \
Expand Down
31 changes: 8 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
1 change: 1 addition & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
24 changes: 15 additions & 9 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading
Loading