This is an automated email from the ASF dual-hosted git repository.
shahar1 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 aee26d49b6b Fix airflowctl ignoring --env for non-auth commands
(#73808)
aee26d49b6b is described below
commit aee26d49b6bdf9713e46e012cd3d7516f2cc7d1e
Author: kuse <[email protected]>
AuthorDate: Tue Oct 27 18:48:25 2026 +0800
Fix airflowctl ignoring --env for non-auth commands (#73808)
Honor --env when resolving airflowctl API credentials
Forward the selected environment through decorated commands and version
--remote, so staging requests use staging credentials.
---
airflow-ctl/src/airflowctl/api/client.py | 15 ++++++--
airflow-ctl/src/airflowctl/ctl/cli_config.py | 2 -
.../src/airflowctl/ctl/commands/version_command.py | 3 +-
airflow-ctl/tests/airflow_ctl/api/test_client.py | 44 ++++++++++++++++++++++
.../ctl/commands/test_version_command.py | 10 ++++-
5 files changed, 66 insertions(+), 8 deletions(-)
diff --git a/airflow-ctl/src/airflowctl/api/client.py
b/airflow-ctl/src/airflowctl/api/client.py
index b1772747346..5ca17140357 100644
--- a/airflow-ctl/src/airflowctl/api/client.py
+++ b/airflow-ctl/src/airflowctl/api/client.py
@@ -516,6 +516,7 @@ class Client(httpx.Client):
def get_client(
kind: Literal[ClientKind.CLI, ClientKind.AUTH, ClientKind.NO_AUTH] =
ClientKind.CLI,
api_token: str | None = None,
+ api_environment: str = "production",
):
"""
Get CLI API client.
@@ -527,10 +528,12 @@ def get_client(
try:
# API URL always loaded from the config file, please save with it if
you are using other than ClientKind.CLI
if kind == ClientKind.NO_AUTH:
- credentials = Credentials(client_kind=kind).load()
+ credentials = Credentials(client_kind=kind,
api_environment=api_environment).load()
resolved_token = None
else:
- credentials = Credentials(client_kind=kind,
api_token=api_token).load()
+ credentials = Credentials(
+ client_kind=kind, api_token=api_token,
api_environment=api_environment
+ ).load()
resolved_token = api_token or credentials.api_token
api_client = Client(
base_url=credentials.api_url or "http://localhost:8080",
@@ -562,7 +565,13 @@ def provide_api_client(
def wrapper(*args, **kwargs) -> RT:
if "api_client" not in kwargs:
api_token = getattr(args[0], "api_token", None) if args else
None
- with get_client(kind=kind, api_token=api_token) as api_client:
+ # ``env`` comes from ARG_AUTH_ENVIRONMENT on every command that
+ # accepts ``--env``/``-e``; Credentials itself still honours
+ # AIRFLOW_CLI_ENVIRONMENT over this value.
+ api_environment = (getattr(args[0], "env", None) if args else
None) or "production"
+ with get_client(
+ kind=kind, api_token=api_token,
api_environment=api_environment
+ ) as api_client:
return func(*args, api_client=api_client, **kwargs)
# The CLI API Client should be only passed for Mocking and Testing
return func(*args, **kwargs)
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index b0f229be2ff..25c14203b67 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -837,8 +837,6 @@ class CommandFactory:
)
args.append(ARG_OUTPUT)
- # ``-e/--env`` is inert here (nothing reads ``args.env``), so
widening it would only let more
- # commands accept it and silently target production:
https://github.com/apache/airflow/issues/70519
if any(operation.get("name").startswith(cmd) for cmd in
self.auth_environment_command_list):
args.append(ARG_AUTH_ENVIRONMENT)
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/version_command.py
b/airflow-ctl/src/airflowctl/ctl/commands/version_command.py
index 42be64980ef..ccddbd2079c 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/version_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/version_command.py
@@ -26,7 +26,8 @@ def version_info(arg):
"""Get version information."""
version_dict = {"airflowctl_version": airflowctl_version}
if arg.remote:
- with get_client(kind=ClientKind.NO_AUTH) as api_client:
+ api_environment = getattr(arg, "env", None) or "production"
+ with get_client(kind=ClientKind.NO_AUTH,
api_environment=api_environment) as api_client:
version_response = api_client.version.get()
version_dict.update(version_response.model_dump())
rich.print(version_dict)
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py
b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index df104786dab..049c384b652 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -23,8 +23,10 @@ import shutil
import subprocess
import sys
import tempfile
+from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
+from urllib.parse import urlparse
import httpx
import pytest
@@ -40,6 +42,7 @@ from airflowctl.api.client import (
_bounded_get_new_password,
get_client,
get_json_error,
+ provide_api_client,
)
from airflowctl.api.operations import ServerResponseError
from airflowctl.exceptions import (
@@ -710,3 +713,44 @@ class TestRetryConfigurationEnvVars:
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "0"
assert result.stderr == ""
+
+
+class TestGetClientEnvironment:
+ """``get_client`` and ``@provide_api_client`` commands resolve the config
file for the requested environment."""
+
+ @staticmethod
+ def _write_environment_config(airflow_home, environment: str, api_url:
str) -> None:
+ (airflow_home /
f"{environment}.json").write_text(json.dumps({"api_url": api_url}),
encoding="utf-8")
+
+ @pytest.fixture(autouse=True)
+ def environments(self, tmp_path):
+ """The module fixture already clears the environment; pin AIRFLOW_HOME
to tmp_path."""
+ os.environ["AIRFLOW_HOME"] = str(tmp_path)
+ self._write_environment_config(tmp_path, "production",
"https://prod.example.com")
+ self._write_environment_config(tmp_path, "staging",
"https://staging.example.com")
+ yield tmp_path
+ del os.environ["AIRFLOW_HOME"]
+
+ def test_uses_the_requested_environment(self):
+ with get_client(kind=ClientKind.CLI, api_token="TOKEN",
api_environment="staging") as client:
+ assert urlparse(str(client.base_url)).hostname ==
"staging.example.com"
+
+ def test_no_auth_uses_the_requested_environment(self):
+ with get_client(kind=ClientKind.NO_AUTH, api_environment="staging") as
client:
+ assert urlparse(str(client.base_url)).hostname ==
"staging.example.com"
+
+ def test_environment_variable_beats_the_explicit_argument(self,
monkeypatch):
+ """AIRFLOW_CLI_ENVIRONMENT keeps precedence, matching Credentials' own
contract."""
+ monkeypatch.setenv("AIRFLOW_CLI_ENVIRONMENT", "staging")
+ with get_client(kind=ClientKind.CLI, api_token="TOKEN",
api_environment="production") as client:
+ assert urlparse(str(client.base_url)).hostname ==
"staging.example.com"
+
+ def test_decorator_forwards_the_env_argument(self):
+ @provide_api_client(kind=ClientKind.CLI)
+ def command_under_test(args, api_client=None):
+ # Inspect the client inside the command: the wrapper closes it
+ # before returning.
+ return urlparse(str(api_client.base_url)).hostname ==
"staging.example.com"
+
+ args = SimpleNamespace(env="staging", api_token="TOKEN")
+ assert command_under_test(args) is True
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_version_command.py
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_version_command.py
index 6168e874d60..4186a2fce46 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_version_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_version_command.py
@@ -52,13 +52,19 @@ class TestVersionCommand:
assert "version" in stdout.getvalue()
assert "git_version" in stdout.getvalue()
assert "airflowctl_version" in stdout.getvalue()
- mock_get_client.assert_called_once_with(kind=ClientKind.NO_AUTH)
+ mock_get_client.assert_called_once_with(kind=ClientKind.NO_AUTH,
api_environment="production")
def test_ctl_version_remote_with_api_token(self, mock_client):
with mock.patch("airflowctl.ctl.commands.version_command.get_client")
as mock_get_client:
mock_get_client.return_value.__enter__.return_value = mock_client
version_info(self.parser.parse_args(["version", "--remote",
"--api-token", "TOKEN"]))
- mock_get_client.assert_called_once_with(kind=ClientKind.NO_AUTH)
+ mock_get_client.assert_called_once_with(kind=ClientKind.NO_AUTH,
api_environment="production")
+
+ def test_ctl_version_remote_with_env(self, mock_client):
+ with mock.patch("airflowctl.ctl.commands.version_command.get_client")
as mock_get_client:
+ mock_get_client.return_value.__enter__.return_value = mock_client
+ version_info(self.parser.parse_args(["version", "--remote",
"--env", "staging"]))
+ mock_get_client.assert_called_once_with(kind=ClientKind.NO_AUTH,
api_environment="staging")
def test_ctl_version_only_local_version(self, mock_client):
"""Test the version command without --remote does not touch
credentials."""