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

shahar1 pushed a commit to branch airflow-ctl/v0-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/airflow-ctl/v0-1-test by this 
push:
     new cf80dbb12ff [airflow-ctl/v0-1-test] Let airflowctl and its integration 
tests use a non-default API server port (#76124) (#76137)
cf80dbb12ff is described below

commit cf80dbb12ffd8e0fd8c3672cf38b28f961431b61
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sat Oct 31 15:13:17 2026 +0200

    [airflow-ctl/v0-1-test] Let airflowctl and its integration tests use a 
non-default API server port (#76124) (#76137)
    
    The airflowctl integration suite could not run on a host where TCP
    8080 is already taken. HOST_PORT was honoured only by the harness's own
    requests calls; the compose file hard-coded 8080 and every CLI command
    under test fell back to http://localhost:8080.
    
    - airflowctl: add AIRFLOW_CLI_API_URL as the default API URL. It is
      resolved at call time for 'auth login' and 'auth token' when
      --api-url is not given, and is the client fallback when no URL is
      stored in the credentials (this also covers 'version --remote'
      without credentials).
    - Compose quickstart: publish the API server on the host port given by
      AIRFLOW_API_SERVER_PORT (default 8080) and document the variable.
    - Integration harness: export AIRFLOW_API_SERVER_PORT derived from
      HOST_PORT before compose up, and pass AIRFLOW_CLI_API_URL to every
      CLI command.
    
    The defaults stay at localhost:8080, so CI behaviour does not change.
    (cherry picked from commit f3b4fec993aa879e013819d3d07e499dd0b4cf7d)
    
    Co-authored-by: Shahar Epstein <[email protected]>
---
 .../docs/howto/docker-compose/docker-compose.yaml  |  2 +-
 airflow-core/docs/howto/docker-compose/index.rst   |  6 ++-
 .../tests/airflowctl_tests/conftest.py             |  9 +++++
 .../tests/airflowctl_tests/test_dag_run_wait.py    | 15 ++++++++
 airflow-ctl/docs/cli-and-env-variables-ref.rst     |  6 +++
 airflow-ctl/src/airflowctl/api/client.py           |  9 ++++-
 airflow-ctl/src/airflowctl/ctl/cli_config.py       |  3 +-
 .../src/airflowctl/ctl/commands/auth_command.py    | 20 +++++++---
 airflow-ctl/tests/airflow_ctl/api/test_client.py   | 29 +++++++++++++++
 .../airflow_ctl/ctl/commands/test_auth_command.py  | 43 ++++++++++++++++++++++
 10 files changed, 130 insertions(+), 12 deletions(-)

diff --git a/airflow-core/docs/howto/docker-compose/docker-compose.yaml 
b/airflow-core/docs/howto/docker-compose/docker-compose.yaml
index 655ceda9375..de68d912017 100644
--- a/airflow-core/docs/howto/docker-compose/docker-compose.yaml
+++ b/airflow-core/docs/howto/docker-compose/docker-compose.yaml
@@ -123,7 +123,7 @@ services:
     <<: *airflow-common
     command: api-server
     ports:
-      - "8080:8080"
+      - "${AIRFLOW_API_SERVER_PORT:-8080}:8080"
     healthcheck:
       test: ["CMD", "curl", "--fail", 
"http://localhost:8080/api/v2/monitor/health";]
       interval: 30s
diff --git a/airflow-core/docs/howto/docker-compose/index.rst 
b/airflow-core/docs/howto/docker-compose/index.rst
index e1045f70ea0..e886937fc3b 100644
--- a/airflow-core/docs/howto/docker-compose/index.rst
+++ b/airflow-core/docs/howto/docker-compose/index.rst
@@ -89,7 +89,7 @@ This file contains several service definitions:
 - ``airflow-scheduler`` - The :doc:`scheduler 
</administration-and-deployment/scheduler>` monitors all tasks and Dags, then 
triggers the
   task instances once their dependencies are complete.
 - ``airflow-dag-processor`` - The Dag processor parses Dag files.
-- ``airflow-api-server`` - The api server is available at 
``http://localhost:8080``.
+- ``airflow-api-server`` - The API server is available at 
``http://localhost:8080``.
 - ``airflow-worker`` - The worker that executes the tasks given by the 
scheduler.
 - ``airflow-triggerer`` - The triggerer runs an event loop for deferrable 
tasks.
 - ``airflow-init`` - The initialization service.
@@ -278,7 +278,7 @@ Accessing the web interface
 
 Once the cluster has started up, you can log in to the web interface and begin 
experimenting with Dags.
 
-The webserver is available at: ``http://localhost:8080``.
+The webserver is available at: ``http://localhost:8080`` (or the port set in 
``AIRFLOW_API_SERVER_PORT``).
 The default account has the login ``airflow`` and the password ``airflow``.
 
 Sending requests to the REST API
@@ -466,6 +466,8 @@ runtime user id which is unknown at the time of building 
the image.
 
+================================+=====================================================+==========================+
 | ``AIRFLOW_IMAGE_NAME``         | Airflow Image to use.                       
        | apache/airflow:|version| |
 
+--------------------------------+-----------------------------------------------------+--------------------------+
+| ``AIRFLOW_API_SERVER_PORT``    | Host port the API server is published on.   
        | ``8080``                 |
++--------------------------------+-----------------------------------------------------+--------------------------+
 | ``AIRFLOW_UID``                | UID of the user to run Airflow containers 
as.       | ``50000``                |
 |                                | Override if you want to use non-default 
Airflow     |                          |
 |                                | UID (for example when you map folders from 
host,    |                          |
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/conftest.py 
b/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
index 13fdee8d97e..262f3e64dff 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
@@ -66,6 +66,13 @@ class _CtlTestState:
     access_token: str | None = None
 
 
+def _get_host_port_number() -> str:
+    port = DOCKER_COMPOSE_HOST_PORT.rpartition(":")[2]
+    if not port.isdigit():
+        raise ValueError(f"HOST_PORT must look like host:port, got 
{DOCKER_COMPOSE_HOST_PORT!r}")
+    return port
+
+
 def _get_access_token() -> str:
     """Log in once per pytest session; every REST call below reuses the 
token."""
     if _CtlTestState.access_token is None:
@@ -187,6 +194,7 @@ def run_command(wait_for_dag_run):
 
     def _run_command(command: str, env_vars: dict, skip_login: bool = False) 
-> str:
         host_envs = os.environ.copy()
+        host_envs["AIRFLOW_CLI_API_URL"] = f"http://{DOCKER_COMPOSE_HOST_PORT}";
         host_envs.update(env_vars)
 
         command_from_config = f"airflowctl {command}"
@@ -382,6 +390,7 @@ def docker_compose_up(tmp_path_factory):
 
     # Set environment variables for the test
     os.environ["AIRFLOW_IMAGE_NAME"] = DOCKER_IMAGE
+    os.environ["AIRFLOW_API_SERVER_PORT"] = _get_host_port_number()
     os.environ["AIRFLOW_CTL_VERSION"] = os.environ.get("AIRFLOW_CTL_VERSION", 
"1.0.0")
     os.environ["ENV_FILE_PATH"] = str(tmp_dir / ".env")
     #
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/test_dag_run_wait.py 
b/airflow-ctl-tests/tests/airflowctl_tests/test_dag_run_wait.py
index fe98e8ebb85..ea26fffee00 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/test_dag_run_wait.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/test_dag_run_wait.py
@@ -41,6 +41,21 @@ def http_error(status_code: int) -> 
requests.exceptions.HTTPError:
     return requests.exceptions.HTTPError(str(status_code), response=response)
 
 
[email protected](
+    ("host_port", "expected"), [("localhost:8080", "8080"), 
("127.0.0.1:18080", "18080")]
+)
+def test_host_port_number_is_derived_from_host_port(monkeypatch, host_port, 
expected):
+    monkeypatch.setattr(conftest, "DOCKER_COMPOSE_HOST_PORT", host_port)
+    assert conftest._get_host_port_number() == expected
+
+
[email protected]("host_port", ["localhost", "localhost:abc"])
+def test_host_port_number_rejects_malformed_host_port(monkeypatch, host_port):
+    monkeypatch.setattr(conftest, "DOCKER_COMPOSE_HOST_PORT", host_port)
+    with pytest.raises(ValueError, match="HOST_PORT"):
+        conftest._get_host_port_number()
+
+
 def test_poll_delays_grow_and_cap():
     assert list(islice(conftest._compute_poll_delays(), 8)) == [0.5, 1.0, 2.0, 
4.0, 8.0, 10.0, 10.0, 10.0]
 
diff --git a/airflow-ctl/docs/cli-and-env-variables-ref.rst 
b/airflow-ctl/docs/cli-and-env-variables-ref.rst
index 0e242dfdb7a..ab08b26a96a 100644
--- a/airflow-ctl/docs/cli-and-env-variables-ref.rst
+++ b/airflow-ctl/docs/cli-and-env-variables-ref.rst
@@ -49,6 +49,12 @@ Environment Variables
     required if you are using the Airflow API and have not set up
     authentication using a different method, such as a username and password.
 
+.. envvar:: AIRFLOW_CLI_API_URL
+
+    The URL of the Airflow API server, used when ``--api-url`` is not passed 
and no URL is
+    stored in the credentials. Defaults to ``http://localhost:8080``. After 
``auth login`` the stored
+    URL takes precedence over this variable; re-run ``auth login`` to change 
it.
+
 .. envvar:: AIRFLOW_CLI_ENVIRONMENT
 
     Environment name to use for the CLI. This is used to determine
diff --git a/airflow-ctl/src/airflowctl/api/client.py 
b/airflow-ctl/src/airflowctl/api/client.py
index 71283b798b4..b15881b7785 100644
--- a/airflow-ctl/src/airflowctl/api/client.py
+++ b/airflow-ctl/src/airflowctl/api/client.py
@@ -534,6 +534,10 @@ class Client(httpx.Client):
         return PluginsOperations(self)
 
 
+def get_default_api_url() -> str:
+    return os.getenv("AIRFLOW_CLI_API_URL") or "http://localhost:8080";
+
+
 # API Client Decorator for CLI Actions
 @contextlib.contextmanager
 def get_client(
@@ -549,7 +553,8 @@ def get_client(
     api_client = None
     api_token = api_token or os.getenv("AIRFLOW_CLI_TOKEN", None)
     try:
-        # API URL always loaded from the config file, please save with it if 
you are using other than ClientKind.CLI
+        # The stored API URL wins; otherwise AIRFLOW_CLI_API_URL or the 
localhost default is used.
+        # Save the URL with the credentials if you are using other than 
ClientKind.CLI
         if kind == ClientKind.NO_AUTH:
             credentials = Credentials(client_kind=kind, 
api_environment=api_environment).load()
             resolved_token = None
@@ -559,7 +564,7 @@ def get_client(
             ).load()
             resolved_token = api_token or credentials.api_token
         api_client = Client(
-            base_url=credentials.api_url or "http://localhost:8080";,
+            base_url=credentials.api_url or get_default_api_url(),
             limits=httpx.Limits(max_keepalive_connections=1, 
max_connections=1),
             token=resolved_token,
             kind=kind,
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index e9aecf9ef0e..92835d36fae 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -305,11 +305,12 @@ ARG_OUTPUT = Arg(
     type=str,
 )
 
+
 # Authentication arguments
 ARG_AUTH_URL = Arg(
     flags=("--api-url",),
     type=str,
-    default="http://localhost:8080";,
+    default=None,
     dest="api_url",
     help="The URL of the Airflow API server",
 )
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
index cf521cbe7ee..b7bfc1a3546 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
@@ -28,7 +28,13 @@ import keyring
 import rich
 from keyring.errors import NoKeyringError
 
-from airflowctl.api.client import NEW_API_CLIENT, ClientKind, Credentials, 
provide_api_client
+from airflowctl.api.client import (
+    NEW_API_CLIENT,
+    ClientKind,
+    Credentials,
+    get_default_api_url,
+    provide_api_client,
+)
 from airflowctl.api.datamodels.auth_generated import LoginBody
 from airflowctl.ctl.console_formatting import AirflowConsole
 
@@ -38,6 +44,7 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
     """Login to a provider."""
     success_message = "[green]Login successful! Welcome to airflowctl![/green]"
 
+    api_url = args.api_url or get_default_api_url()
     username = args.username
     password = args.password
     token = args.api_token or os.environ.get("AIRFLOW_CLI_TOKEN")
@@ -52,7 +59,7 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
                 "[blue] Alternatively, you can use --username and --password 
to login.[/blue]"
             )
             sys.exit(1)
-        rich.print(f"[blue]Credentials for[/blue] [bold]{args.env}[/bold] 
[blue]({args.api_url})[/blue]")
+        rich.print(f"[blue]Credentials for[/blue] [bold]{args.env}[/bold] 
[blue]({api_url})[/blue]")
         if not username:
             username = input(f"[{args.env}] Username: ")
         if not password:
@@ -64,14 +71,14 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
             rich.print("[red]The --skip-keyring is not compatible with 
username and password login.")
             sys.exit(1)
         credentials = Credentials(
-            api_url=args.api_url,
+            api_url=api_url,
             api_token="",
             api_environment=args.env,
             client_kind=ClientKind.AUTH,
         )
         # After logging in, the token will be saved in the credentials file
         try:
-            api_client.refresh_base_url(base_url=args.api_url, 
kind=ClientKind.AUTH)
+            api_client.refresh_base_url(base_url=api_url, kind=ClientKind.AUTH)
             login_response = api_client.login.login_with_username_and_password(
                 LoginBody(
                     username=username,
@@ -97,7 +104,7 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
         sys.exit(1)
 
     Credentials(
-        api_url=args.api_url,
+        api_url=api_url,
         api_token=token,
         api_environment=args.env,
     ).save(args.skip_keyring)
@@ -107,11 +114,12 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
 @provide_api_client(kind=ClientKind.AUTH)
 def get_token(args, api_client=NEW_API_CLIENT) -> None:
     """Generate and print a JWT token for the given credentials to stdout."""
+    api_url = args.api_url or get_default_api_url()
     username = args.username or input("Username: ")
     password = args.password or getpass.getpass("Password: ")
 
     try:
-        api_client.refresh_base_url(base_url=args.api_url, 
kind=ClientKind.AUTH)
+        api_client.refresh_base_url(base_url=api_url, kind=ClientKind.AUTH)
         login_response = api_client.login.login_with_username_and_password(
             LoginBody(username=username, password=password)
         )
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py 
b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index a87616efae3..f602535b00c 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -44,6 +44,7 @@ from airflowctl.api.client import (
     _request_method,
     _should_retry_api_request,
     get_client,
+    get_default_api_url,
     get_json_error,
     provide_api_client,
 )
@@ -698,6 +699,34 @@ def test_get_client_allows_explicit_token_without_config():
         assert str(client.base_url) == "http://localhost:8080/api/v2/";
 
 
[email protected](
+    ("env_value", "expected"),
+    [
+        (None, "http://localhost:8080";),
+        ("", "http://localhost:8080";),
+        ("http://localhost:18080";, "http://localhost:18080";),
+    ],
+)
+def test_get_default_api_url(monkeypatch, env_value, expected):
+    if env_value is None:
+        monkeypatch.delenv("AIRFLOW_CLI_API_URL", raising=False)
+    else:
+        monkeypatch.setenv("AIRFLOW_CLI_API_URL", env_value)
+    assert get_default_api_url() == expected
+
+
[email protected](
+    os.environ,
+    {
+        "AIRFLOW_CLI_ENVIRONMENT": "TEST_GET_CLIENT_API_URL_FROM_ENV",
+        "AIRFLOW_CLI_API_URL": "http://localhost:18080";,
+    },
+)
+def test_get_client_falls_back_to_api_url_env_var():
+    with get_client(kind=ClientKind.CLI, api_token="TEST_TOKEN") as client:
+        assert str(client.base_url) == "http://localhost:18080/api/v2/";
+
+
 @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": 
"TEST_GET_CLIENT_NO_AUTH_WITHOUT_CONFIG"})
 @patch("airflowctl.api.client.keyring")
 def test_get_client_no_auth_without_config(mock_keyring):
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py 
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py
index 9f442f40b90..5bfad37c84f 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py
@@ -36,6 +36,49 @@ class TestCliAuthCommands:
         access_token="TEST_TOKEN",
     )
 
+    @pytest.mark.parametrize(
+        ("cli_args", "expected_api_url"),
+        [
+            ([], "http://localhost:18080";),
+            (["--api-url", "http://localhost:19090";], 
"http://localhost:19090";),
+        ],
+    )
+    @patch.dict(
+        os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN", "AIRFLOW_CLI_API_URL": 
"http://localhost:18080"}
+    )
+    @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": 
"TEST_AUTH_LOGIN_API_URL"})
+    @patch("airflowctl.api.client.keyring")
+    def test_login_api_url_resolution(self, mock_keyring, cli_args, 
expected_api_url, monkeypatch):
+        with tempfile.TemporaryDirectory() as temp_airflow_home:
+            monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+            mock_keyring.get_password.return_value = None
+
+            auth_command.login(
+                self.parser.parse_args(["auth", "login", *cli_args]), 
api_client=mock.MagicMock()
+            )
+
+            with open(os.path.join(temp_airflow_home, 
"TEST_AUTH_LOGIN_API_URL.json")) as f:
+                assert json.load(f) == {"api_url": expected_api_url}
+
+    @pytest.mark.parametrize(
+        ("cli_args", "expected_api_url"),
+        [
+            ([], "http://localhost:18080";),
+            (["--api-url", "http://localhost:19090";], 
"http://localhost:19090";),
+        ],
+    )
+    @patch.dict(os.environ, {"AIRFLOW_CLI_API_URL": "http://localhost:18080"})
+    def test_get_token_api_url_resolution(self, cli_args, expected_api_url):
+        api_client = mock.MagicMock()
+        api_client.login.login_with_username_and_password.return_value = 
self.login_response
+
+        auth_command.get_token(
+            self.parser.parse_args(["auth", "token", "--username", "u", 
"--password", "p", *cli_args]),
+            api_client=api_client,
+        )
+
+        
api_client.refresh_base_url.assert_called_once_with(base_url=expected_api_url, 
kind=ClientKind.AUTH)
+
     @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
     @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_AUTH_LOGIN"})
     @patch("airflowctl.api.client.keyring")

Reply via email to