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")