This is an automated email from the ASF dual-hosted git repository.

ash pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 67a86d50c16 Make the gunicorn API server respect GUNICORN_CMD_ARGS 
again (#62522)
67a86d50c16 is described below

commit 67a86d50c165bd561095cb23f837fc79934e9511
Author: Ash Berlin-Taylor <[email protected]>
AuthorDate: Thu Feb 26 17:26:07 2026 +0000

    Make the gunicorn API server respect GUNICORN_CMD_ARGS again (#62522)
    
    Since our new Arbiter and custom code doesn't invoke Gunicorn via the full 
CLI
    path of normal gunicorn, we have to manually call this.
    
    This lets us give control to users to accept any and all gunicorn args 
without
    us having to put them in our CLI, or keep up to date with changes. This env
    var is something stock `gunicorn` already respects too.
---
 .../src/airflow/api_fastapi/gunicorn_app.py        | 12 ++++-
 .../unit/cli/commands/test_gunicorn_monitor.py     | 52 +++++++++++++++++++---
 2 files changed, 57 insertions(+), 7 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/gunicorn_app.py 
b/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
index d0b39c4cb4e..e52a681b006 100644
--- a/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
+++ b/airflow-core/src/airflow/api_fastapi/gunicorn_app.py
@@ -170,11 +170,21 @@ class AirflowGunicornApp(BaseApplication):
         super().__init__()
 
     def load_config(self) -> None:
-        """Load configuration from options dict into gunicorn config."""
+        """Load configuration from options dict, then GUNICORN_CMD_ARGS env 
var."""
         for key, value in self.options.items():
             if key in self.cfg.settings and value is not None:
                 self.cfg.set(key.lower(), value)
 
+        cmd_args = self.cfg.get_cmd_args_from_env()
+        if cmd_args:
+            log.info("Applying GUNICORN_CMD_ARGS: %s", cmd_args)
+            parser = self.cfg.parser()
+            env_args = parser.parse_args(cmd_args)
+            for k, v in vars(env_args).items():
+                if v is None or k == "args":
+                    continue
+                self.cfg.set(k.lower(), v)
+
     def load(self) -> Any:
         """Load and return the WSGI/ASGI application."""
         if self.application is None:
diff --git a/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py 
b/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py
index c526efc8c5d..4964618a385 100644
--- a/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py
+++ b/airflow-core/tests/unit/cli/commands/test_gunicorn_monitor.py
@@ -21,6 +21,7 @@ from __future__ import annotations
 from unittest import mock
 
 import pytest
+from gunicorn.config import Config
 
 
 class TestAirflowArbiter:
@@ -299,24 +300,63 @@ class TestAirflowArbiter:
 class TestAirflowGunicornApp:
     """Tests for the AirflowGunicornApp class."""
 
-    def test_load_config(self):
+    def test_load_config(self, monkeypatch):
         """Test that options are loaded into gunicorn config."""
         from airflow.api_fastapi.gunicorn_app import AirflowGunicornApp
 
+        monkeypatch.delenv("GUNICORN_CMD_ARGS", raising=False)
+
         def mock_init(self, options):
             pass  # Do nothing, we'll set up state manually
 
         with mock.patch.object(AirflowGunicornApp, "__init__", mock_init):
             app = AirflowGunicornApp.__new__(AirflowGunicornApp)
             app.options = {"workers": 4, "bind": "0.0.0.0:8080"}
-            app.cfg = mock.MagicMock()
-            app.cfg.settings = {"workers": mock.MagicMock(), "bind": 
mock.MagicMock()}
+            app.cfg = Config()
+
+            app.load_config()
+
+            assert app.cfg.workers == 4
+            assert app.cfg.bind == ["0.0.0.0:8080"]
+
+    def test_load_config_respects_gunicorn_cmd_args(self, monkeypatch):
+        """Test that GUNICORN_CMD_ARGS env var values are applied to config."""
+        from airflow.api_fastapi.gunicorn_app import AirflowGunicornApp
+
+        monkeypatch.setenv("GUNICORN_CMD_ARGS", "--worker-tmp-dir /dev/shm 
--max-requests 1000")
+
+        def mock_init(self, options):
+            pass
+
+        with mock.patch.object(AirflowGunicornApp, "__init__", mock_init):
+            app = AirflowGunicornApp.__new__(AirflowGunicornApp)
+            app.options = {"workers": 4}
+            app.cfg = Config()
+
+            app.load_config()
+
+            assert app.cfg.worker_tmp_dir == "/dev/shm"
+            assert app.cfg.max_requests == 1000
+            assert app.cfg.workers == 4
+
+    def test_load_config_gunicorn_cmd_args_overrides_options(self, 
monkeypatch):
+        """Test that GUNICORN_CMD_ARGS takes precedence over programmatic 
options."""
+        monkeypatch.setenv("GUNICORN_CMD_ARGS", "--workers 8")
+        from gunicorn.config import Config
+
+        from airflow.api_fastapi.gunicorn_app import AirflowGunicornApp
+
+        def mock_init(self, options):
+            pass
+
+        with mock.patch.object(AirflowGunicornApp, "__init__", mock_init):
+            app = AirflowGunicornApp.__new__(AirflowGunicornApp)
+            app.options = {"workers": 4}
+            app.cfg = Config()
 
             app.load_config()
 
-            assert app.cfg.set.call_count == 2
-            app.cfg.set.assert_any_call("workers", 4)
-            app.cfg.set.assert_any_call("bind", "0.0.0.0:8080")
+            assert app.cfg.workers == 8
 
     def test_load_returns_airflow_app(self):
         """Test that load() returns the Airflow FastAPI app."""

Reply via email to