Skip to content
Open
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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,14 @@ ENV PYTHONPATH=/pgadmin4
# Copy in the code and docs
COPY --from=app-builder /pgadmin4/web /pgadmin4
COPY --from=docs-builder /pgadmin4/docs/en_US/_build/html/ /pgadmin4/docs
COPY pkg/docker/run_pgadmin.py pkg/docker/gunicorn_config.py /pgadmin4/
COPY pkg/docker/run_pgadmin.py /pgadmin4/
COPY pkg/docker/entrypoint.sh /entrypoint.sh

# License files
COPY LICENSE /pgadmin4/LICENSE

# Configure everything in one RUN step
RUN /venv/bin/python3 -m pip install --no-cache-dir gunicorn==23.0.0 && \
RUN /venv/bin/python3 -m pip install --no-cache-dir granian==2.7.2 && \
find / -type d -name '__pycache__' -exec rm -rf {} + && \
useradd -r -u 5050 -g root -s /sbin/nologin pgadmin && \
mkdir -p /run/pgadmin /var/lib/pgadmin && \
Expand Down
30 changes: 18 additions & 12 deletions pkg/docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -178,25 +178,31 @@ if [ -z "${PGADMIN_DISABLE_POSTFIX}" ]; then
sudo /usr/sbin/postfix start
fi

# Get the session timeout from the pgAdmin config. We'll use this (in seconds)
# to define the Gunicorn worker timeout
TIMEOUT=$(cd /pgadmin4 && /venv/bin/python3 -c 'import config; print(config.SESSION_EXPIRATION_TIME * 60 * 60 * 24)')

# NOTE: currently pgadmin can run only with 1 worker due to sessions implementation
# Using --threads to have multi-threaded single-process worker

if [ -n "${PGADMIN_ENABLE_SOCK}" ]; then
BIND_ADDRESS="unix:/run/pgadmin/pgadmin.sock"
BIND_ARGS="--uds /run/pgadmin/pgadmin.sock"
else
BIND_ARGS="--host ${PGADMIN_LISTEN_ADDRESS:-[::]} --port ${PGADMIN_LISTEN_PORT:-80}"
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
BIND_ADDRESS="${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-443}"
else
BIND_ADDRESS="${PGADMIN_LISTEN_ADDRESS:-[::]}:${PGADMIN_LISTEN_PORT:-80}"
BIND_ARGS="--host ${PGADMIN_LISTEN_ADDRESS:-[::]} --port ${PGADMIN_LISTEN_PORT:-443}"
fi
fi

if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
exec /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --timeout "${TIMEOUT}" --bind "${BIND_ADDRESS}" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" --keyfile /certs/server.key --certfile /certs/server.cert -c gunicorn_config.py run_pgadmin:app
if [ "${GUNICORN_ACCESS_LOGFILE:--}" = "-" ]; then
ACCESS_LOG_ARGS="--access-log"
elif [ -n "${GUNICORN_ACCESS_LOGFILE}" ]; then
echo "Warning: GUNICORN_ACCESS_LOGFILE file paths are not supported with Granian. Access logging disabled." >&2
ACCESS_LOG_ARGS="--no-access-log"
else
exec /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --limit-request-fields "${GUNICORN_LIMIT_REQUEST_FIELDS:-100}" --limit-request-field_size "${GUNICORN_LIMIT_REQUEST_FIELD_SIZE:-8190}" --timeout "${TIMEOUT}" --bind "${BIND_ADDRESS}" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" -c gunicorn_config.py run_pgadmin:app
ACCESS_LOG_ARGS="--no-access-log"
fi

TLS_ARGS=""
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert"
Comment on lines +201 to +203
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and inspect the actual file
find . -name "entrypoint.sh" -path "*/docker/*" | head -5

Repository: pgadmin-org/pgadmin4

Length of output: 90


🏁 Script executed:

# Check if Granian is referenced elsewhere in the entrypoint
rg "granian|tls|ssl|TLS|SSL" pkg/docker/entrypoint.sh -i -A 2 -B 2 | head -80

Repository: pgadmin-org/pgadmin4

Length of output: 1198


🌐 Web query:

Granian 2.7.2 --ssl-protocol-min default value documentation

💡 Result:

In Granian 2.7.2, the CLI option:

  • --ssl-protocol-min [tls1.2|tls1.3]

has a default value of tls1.3 (env var GRANIAN_SSL_PROTOCOL_MIN). [1]


🌐 Web query:

Gunicorn SSL certificate keyfile default TLS version

💡 Result:

  • keyfile (private key) default: None (no key configured unless you set it). (docs.gunicorn.org)
  • certfile default: None. (docs.gunicorn.org)
  • Gunicorn “default TLS version” (ssl_version) default: ssl.PROTOCOL_TLS (configured in Gunicorn as _SSLMethod.PROTOCOL_TLS). This means Gunicorn will negotiate the highest protocol version supported by both client and server, with the effective enabled/disabled versions depending on your Python + OpenSSL build and defaults. (docs.gunicorn.org)

How to explicitly control it:

  • Set a minimum (common choice): ssl_version = "TLSv1_2" (Gunicorn example also notes this as minimum TLS 1.2 for HTTP/2). (gunicorn.org)
  • For finer control (Gunicorn ≥ 20.2): use ssl_context and set context.minimum_version (e.g., TLS 1.3). (docs.gunicorn.org)

Citations:


🏁 Script executed:

# Check if there are any existing GRANIAN_* environment variables in the codebase to confirm naming convention
rg "GRANIAN_" pkg/docker/ -i

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Check Python and OpenSSL versions in the Dockerfile
rg "FROM|python|openssl" pkg/docker/Dockerfile -i -A 1 | head -30

Repository: pgadmin-org/pgadmin4

Length of output: 125


🏁 Script executed:

# Find the Dockerfile
find . -name "Dockerfile*" -type f | head -10

Repository: pgadmin-org/pgadmin4

Length of output: 76


🏁 Script executed:

# Check the environment variable pattern used in entrypoint.sh
rg "^\s*\$\{[A-Z_]+:-" pkg/docker/entrypoint.sh | head -10

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Check environment variable patterns in entrypoint.sh
rg "\$\{" pkg/docker/entrypoint.sh | head -20

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Examine the Dockerfile to understand the base image and Python version
head -30 ./Dockerfile

Repository: pgadmin-org/pgadmin4

Length of output: 860


🏁 Script executed:

# Get all environment variable patterns with defaults in entrypoint.sh
grep -E '\$\{[A-Z_]+:-' pkg/docker/entrypoint.sh

Repository: pgadmin-org/pgadmin4

Length of output: 860


🏁 Script executed:

# Look for the section with TLS and surrounding context to understand the env var pattern
sed -n '190,210p' pkg/docker/entrypoint.sh

Repository: pgadmin-org/pgadmin4

Length of output: 769


Don't silently raise the TLS floor to 1.3.

Granian 2.7.2 defaults --ssl-protocol-min to tls1.3, while Gunicorn's ssl.PROTOCOL_TLS defaults to negotiating TLS 1.2+. Because this branch only adds --ssl-keyfile/--ssl-certificate, TLS-enabled containers now reject TLS 1.2 clients without any opt-in. If that isn't intentional, set an explicit minimum or surface it as a configurable migration knob.

🛠️ Suggested change
 TLS_ARGS=""
 if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
-    TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert"
+    TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert --ssl-protocol-min ${GRANIAN_SSL_PROTOCOL_MIN:-tls1.2}"
 fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
TLS_ARGS=""
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert"
TLS_ARGS=""
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert --ssl-protocol-min ${GRANIAN_SSL_PROTOCOL_MIN:-tls1.2}"
fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/docker/entrypoint.sh` around lines 201 - 203, The TLS branch that sets
TLS_ARGS when PGADMIN_ENABLE_TLS is present currently only adds
--ssl-keyfile/--ssl-certificate and relies on defaults that may raise the
minimum protocol to TLS1.3; update entrypoint.sh so TLS_ARGS also explicitly
sets the minimum protocol (e.g., add --ssl-protocol-min tls1.2) or read a new
env var (e.g., PGADMIN_SSL_MIN_PROTOCOL) to allow opt-in upgrades, ensuring the
change is applied where TLS_ARGS is constructed and used.

fi

# Keep the existing environment variables for backward compatibility.
exec /venv/bin/granian --interface wsgi --workers 1 --blocking-threads "${GUNICORN_THREADS:-25}" ${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
Comment on lines +206 to +207
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "entrypoint.sh" -type f | head -20

Repository: pgadmin-org/pgadmin4

Length of output: 90


🏁 Script executed:

cat -n pkg/docker/entrypoint.sh | sed -n '200,215p'

Repository: pgadmin-org/pgadmin4

Length of output: 495


🌐 Web query:

Granian 2.7.2 backpressure blocking-threads difference WSGI

💡 Result:

In Granian 2.7.2, nothing changed about backpressure or blocking-threads specifically—the 2.7.2 patch release is about ASGI websocket handling/metrics fixes (e.g., rejection flow, Django Channels hang) and dependency bumps. [1]

What “backpressure” is (and why it matters for WSGI)

Granian’s backpressure is a per-worker safeguard that stops the worker accept-loop from taking more connections when “too much work” is already queued for Python. Think of it as a server-level secondary backlog (in addition to the OS listen backlog). [2]

Key WSGI nuance: because it acts at the connection accept level, it limits connections, not individual HTTP requests; lots of keep-alive connections (common behind reverse proxies) can “consume” backpressure capacity. [2]

What “blocking-threads” is (and how it differs)

Granian’s blocking threads are the threads in a worker that interact with the Python interpreter (i.e., execute your app and contend for the GIL). [2]

Granian explicitly notes:

  • On async interfaces (ASGI/RSGI), this setting is effectively fixed to 1 (interop mainly happens via the asyncio event loop holding the GIL). [2]
  • On WSGI (sync), it becomes the maximum number of Python-executing threads per worker (i.e., your WSGI thread pool). [2]

Practical difference for WSGI

  • --blocking-threads = “How many requests can run Python code concurrently (per worker)?”
  • --backpressure = “How much total concurrency/queueing pressure (per worker) will I allow before I stop accepting more connections?”

Granian generally recommends not over-tuning the threadpool and instead setting an appropriate backpressure limit for your environment/workload. [2][3]

Rule of thumb for WSGI tuning

  • If your WSGI requests are mostly CPU-bound / rarely release the GIL, increasing backpressure won’t help much (requests just wait on the GIL). [2]
  • If your WSGI requests spend time in I/O that releases the GIL (DB/network), a higher backpressure can help keep the worker busy without letting the queue explode. [2]

Sources: GitHub release notes for 2.7.2 [1]; Granian README / docs on workers, blocking threads, and backpressure [2]; PyPI project description mirroring the same guidance [3].


Add explicit Granian backpressure; GUNICORN_THREADS alone is not enough.

Granian treats --blocking-threads (Python thread pool size) and --backpressure (connection admission control) as separate knobs. With only --blocking-threads set from GUNICORN_THREADS, request admission falls back to Granian's default backpressure, which does not account for your deployment's concurrency tuning. This leaves the server vulnerable to queue buildup under concurrent load. Wire --backpressure explicitly to maintain predictable overload behavior.

🛠️ Suggested change
 # Keep the existing environment variables for backward compatibility.
-exec /venv/bin/granian --interface wsgi --workers 1 --blocking-threads "${GUNICORN_THREADS:-25}" ${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
+GRANIAN_BACKPRESSURE="${GRANIAN_BACKPRESSURE:-${GUNICORN_THREADS:-25}}"
+exec /venv/bin/granian --interface wsgi --workers 1 \
+    --blocking-threads "${GUNICORN_THREADS:-25}" \
+    --backpressure "${GRANIAN_BACKPRESSURE}" \
+    ${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Keep the existing environment variables for backward compatibility.
exec /venv/bin/granian --interface wsgi --workers 1 --blocking-threads "${GUNICORN_THREADS:-25}" ${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
# Keep the existing environment variables for backward compatibility.
GRANIAN_BACKPRESSURE="${GRANIAN_BACKPRESSURE:-${GUNICORN_THREADS:-25}}"
exec /venv/bin/granian --interface wsgi --workers 1 \
--blocking-threads "${GUNICORN_THREADS:-25}" \
--backpressure "${GRANIAN_BACKPRESSURE}" \
${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
🧰 Tools
🪛 Shellcheck (0.11.0)

[info] 207-207: Double quote to prevent globbing and word splitting.

(SC2086)


[info] 207-207: Double quote to prevent globbing and word splitting.

(SC2086)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/docker/entrypoint.sh` around lines 206 - 207, The entrypoint currently
sets Granian's thread pool via --blocking-threads using the GUNICORN_THREADS env
var but omits --backpressure, so connection admission uses Granian's default;
update the exec invocation that runs granian (the line invoking
/venv/bin/granian with --interface, --workers, --blocking-threads and existing
${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS}) to add an explicit --backpressure
flag wired to an environment variable (e.g.
${GUNICORN_BACKPRESSURE:-<sensible-default>}) so deployment concurrency tuning
is respected and overload admission is predictable.


45 changes: 0 additions & 45 deletions pkg/docker/gunicorn_config.py

This file was deleted.

4 changes: 3 additions & 1 deletion web/regression/python_test_utils/csrf_test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ def generate_csrf_token(self, *args, **kwargs):
}
self._add_cookies_to_wsgi(environ_overrides)

with self.app.test_request_context():
with self.app.test_request_context(
environ_overrides=environ_overrides
):
# Now, we call Flask-WTF's method of generating a CSRF token...
csrf_token = generate_csrf()
# ...which also sets a value in `flask.session`, so we need to
Expand Down