Skip to content

Commit 80cefde

Browse files
authored
Allow remote version check without authentication (#65099)
* fix(cli): allow remote version check without local config file * fix(cli): introduce NO_AUTH client * test(cli): add unit test for no-auth commands
1 parent ad213e0 commit 80cefde

5 files changed

Lines changed: 97 additions & 18 deletions

File tree

airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,15 @@ def date_param():
123123
"variables delete --variable-key=test_key",
124124
"variables delete --variable-key=test_import_var",
125125
"variables delete --variable-key=test_import_var_with_desc",
126-
# Version command
127-
"version --remote",
128126
# Plugins command
129127
"plugins list",
130128
"plugins list-import-errors",
131129
]
132130

131+
NO_AUTH_TEST_COMMANDS = [
132+
"version --remote",
133+
]
134+
133135
DATE_PARAM_1 = date_param()
134136
DATE_PARAM_2 = date_param()
135137

@@ -191,3 +193,17 @@ def test_airflowctl_commands_skip_keyring(command: str, api_token: str, run_comm
191193
},
192194
skip_login=True,
193195
)
196+
197+
198+
@pytest.mark.parametrize("command", NO_AUTH_TEST_COMMANDS)
199+
def test_airflowctl_no_auth_commands(command: str, run_command, tmp_path):
200+
"""Test airflowctl no-auth commands without login or persisted credentials."""
201+
run_command(
202+
command=command,
203+
env_vars={
204+
"AIRFLOW_HOME": str(tmp_path),
205+
"AIRFLOW_CLI_ENVIRONMENT": "no-auth",
206+
"AIRFLOW_CLI_DEBUG_MODE": "false",
207+
},
208+
skip_login=True,
209+
)

airflow-ctl/src/airflowctl/api/client.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class ClientKind(enum.Enum):
9898

9999
CLI = "cli"
100100
AUTH = "auth"
101+
NO_AUTH = "no_auth"
101102

102103

103104
def add_correlation_id(request: httpx.Request):
@@ -253,6 +254,8 @@ def load(self) -> Credentials:
253254
with open(config_path) as f:
254255
credentials = json.load(f)
255256
self.api_url = credentials["api_url"]
257+
if self.client_kind == ClientKind.NO_AUTH:
258+
return self
256259
if self.api_token is not None:
257260
return self
258261
if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
@@ -294,16 +297,17 @@ def load(self) -> Credentials:
294297
raise AirflowCtlKeyringException("Keyring backend is not available") from e
295298
self.api_token = None
296299
except FileNotFoundError:
297-
# This is expected during the auth login command
298-
if self.client_kind != ClientKind.AUTH:
300+
# This is expected during the auth login command.
301+
# Also allow token-only usage without local config (for commands like `version --remote`).
302+
if self.client_kind not in (ClientKind.AUTH, ClientKind.NO_AUTH) and self.api_token is None:
299303
raise AirflowCtlCredentialNotFoundException("No credentials file found. Please login first.")
300304

301305
return self
302306

303307

304308
class BearerAuth(httpx.Auth):
305-
def __init__(self, token: str):
306-
self.token: str = token
309+
def __init__(self, token: str | None):
310+
self.token: str | None = token
307311

308312
def auth_flow(self, request: httpx.Request):
309313
if self.token:
@@ -332,11 +336,13 @@ def __init__(
332336
self,
333337
*,
334338
base_url: str,
335-
token: str,
336-
kind: Literal[ClientKind.CLI, ClientKind.AUTH] = ClientKind.CLI,
339+
token: str | None = None,
340+
kind: Literal[ClientKind.CLI, ClientKind.AUTH, ClientKind.NO_AUTH] = ClientKind.CLI,
337341
**kwargs: Any,
338342
) -> None:
339-
auth = BearerAuth(token)
343+
auth: httpx.Auth | None = None
344+
if kind != ClientKind.NO_AUTH:
345+
auth = BearerAuth(token)
340346
kwargs["base_url"] = self._get_base_url(base_url=base_url, kind=kind)
341347
pyver = f"{'.'.join(map(str, sys.version_info[:3]))}"
342348
super().__init__(
@@ -347,14 +353,18 @@ def __init__(
347353
)
348354

349355
def refresh_base_url(
350-
self, base_url: str, kind: Literal[ClientKind.AUTH, ClientKind.CLI] = ClientKind.CLI
356+
self,
357+
base_url: str,
358+
kind: Literal[ClientKind.AUTH, ClientKind.CLI, ClientKind.NO_AUTH] = ClientKind.CLI,
351359
):
352360
"""Refresh the base URL of the client."""
353361
self.base_url = URL(self._get_base_url(base_url=base_url, kind=kind))
354362

355363
@classmethod
356364
def _get_base_url(
357-
cls, base_url: str, kind: Literal[ClientKind.AUTH, ClientKind.CLI] = ClientKind.CLI
365+
cls,
366+
base_url: str,
367+
kind: Literal[ClientKind.AUTH, ClientKind.CLI, ClientKind.NO_AUTH] = ClientKind.CLI,
358368
) -> str:
359369
"""Get the base URL of the client."""
360370
base_url = base_url.rstrip("/")
@@ -466,7 +476,10 @@ def plugins(self):
466476

467477
# API Client Decorator for CLI Actions
468478
@contextlib.contextmanager
469-
def get_client(kind: Literal[ClientKind.CLI, ClientKind.AUTH] = ClientKind.CLI, api_token: str | None = None):
479+
def get_client(
480+
kind: Literal[ClientKind.CLI, ClientKind.AUTH, ClientKind.NO_AUTH] = ClientKind.CLI,
481+
api_token: str | None = None,
482+
):
470483
"""
471484
Get CLI API client.
472485
@@ -476,11 +489,16 @@ def get_client(kind: Literal[ClientKind.CLI, ClientKind.AUTH] = ClientKind.CLI,
476489
api_token = api_token or os.getenv("AIRFLOW_CLI_TOKEN", None)
477490
try:
478491
# API URL always loaded from the config file, please save with it if you are using other than ClientKind.CLI
479-
credentials = Credentials(client_kind=kind, api_token=api_token).load()
492+
if kind == ClientKind.NO_AUTH:
493+
credentials = Credentials(client_kind=kind).load()
494+
resolved_token = None
495+
else:
496+
credentials = Credentials(client_kind=kind, api_token=api_token).load()
497+
resolved_token = api_token or credentials.api_token
480498
api_client = Client(
481499
base_url=credentials.api_url or "http://localhost:8080",
482500
limits=httpx.Limits(max_keepalive_connections=1, max_connections=1),
483-
token=str(api_token or credentials.api_token),
501+
token=resolved_token,
484502
kind=kind,
485503
)
486504
yield api_client
@@ -492,7 +510,7 @@ def get_client(kind: Literal[ClientKind.CLI, ClientKind.AUTH] = ClientKind.CLI,
492510

493511

494512
def provide_api_client(
495-
kind: Literal[ClientKind.CLI, ClientKind.AUTH] = ClientKind.CLI,
513+
kind: Literal[ClientKind.CLI, ClientKind.AUTH, ClientKind.NO_AUTH] = ClientKind.CLI,
496514
) -> Callable[[Callable[PS, RT]], Callable[PS, RT]]:
497515
"""
498516
Provide a CLI API Client to the decorated function.

airflow-ctl/src/airflowctl/ctl/commands/version_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def version_info(arg):
2626
"""Get version information."""
2727
version_dict = {"airflowctl_version": airflowctl_version}
2828
if arg.remote:
29-
with get_client(kind=ClientKind.CLI, api_token=getattr(arg, "api_token", None)) as api_client:
29+
with get_client(kind=ClientKind.NO_AUTH) as api_client:
3030
version_response = api_client.version.get()
3131
version_dict.update(version_response.model_dump())
3232
rich.print(version_dict)

airflow-ctl/tests/airflow_ctl/api/test_client.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
import time_machine
2929
from httpx import URL
3030

31-
from airflowctl.api.client import Client, ClientKind, Credentials, _bounded_get_new_password
31+
from airflowctl.api.client import Client, ClientKind, Credentials, _bounded_get_new_password, get_client
3232
from airflowctl.api.operations import ServerResponseError
3333
from airflowctl.exceptions import (
3434
AirflowCtlCredentialNotFoundException,
@@ -105,12 +105,16 @@ def handle_request(request: httpx.Request) -> httpx.Response:
105105
[
106106
("http://localhost:8080", ClientKind.CLI, "http://localhost:8080/api/v2/"),
107107
("http://localhost:8080", ClientKind.AUTH, "http://localhost:8080/auth/"),
108+
("http://localhost:8080", ClientKind.NO_AUTH, "http://localhost:8080/api/v2/"),
108109
("https://example.com", ClientKind.CLI, "https://example.com/api/v2/"),
109110
("https://example.com", ClientKind.AUTH, "https://example.com/auth/"),
111+
("https://example.com", ClientKind.NO_AUTH, "https://example.com/api/v2/"),
110112
("http://localhost:8080/", ClientKind.CLI, "http://localhost:8080/api/v2/"),
111113
("http://localhost:8080/", ClientKind.AUTH, "http://localhost:8080/auth/"),
114+
("http://localhost:8080/", ClientKind.NO_AUTH, "http://localhost:8080/api/v2/"),
112115
("https://example.com/", ClientKind.CLI, "https://example.com/api/v2/"),
113116
("https://example.com/", ClientKind.AUTH, "https://example.com/auth/"),
117+
("https://example.com/", ClientKind.NO_AUTH, "https://example.com/api/v2/"),
114118
],
115119
)
116120
def test_refresh_base_url(self, base_url, client_kind, expected_base_url):
@@ -214,6 +218,25 @@ def test_load_no_credentials(self, mock_keyring):
214218

215219
assert not os.path.exists(config_dir)
216220

221+
@patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_NO_CONFIG_WITH_EXPLICIT_TOKEN"})
222+
@patch("airflowctl.api.client.keyring")
223+
def test_load_no_config_with_explicit_token(self, mock_keyring):
224+
cli_client = ClientKind.CLI
225+
credentials = Credentials(client_kind=cli_client, api_token="TEST_TOKEN").load()
226+
227+
assert credentials.api_token == "TEST_TOKEN"
228+
assert credentials.api_url is None
229+
mock_keyring.get_password.assert_not_called()
230+
231+
@patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_NO_CONFIG_NO_AUTH"})
232+
@patch("airflowctl.api.client.keyring")
233+
def test_load_no_config_no_auth_kind(self, mock_keyring):
234+
credentials = Credentials(client_kind=ClientKind.NO_AUTH).load()
235+
236+
assert credentials.api_token is None
237+
assert credentials.api_url is None
238+
mock_keyring.get_password.assert_not_called()
239+
217240
@patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_KEYRING_VALUE_ERROR"})
218241
@patch("airflowctl.api.client.keyring")
219242
def test_load_incorrect_keyring_password(self, mock_keyring):
@@ -400,6 +423,21 @@ def test_credentials_accepts_safe_env():
400423
assert creds.api_environment == "prod-us_1"
401424

402425

426+
@patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_GET_CLIENT_WITH_TOKEN_ONLY"})
427+
def test_get_client_allows_explicit_token_without_config():
428+
with get_client(kind=ClientKind.CLI, api_token="TEST_TOKEN") as client:
429+
assert str(client.base_url) == "http://localhost:8080/api/v2/"
430+
431+
432+
@patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_GET_CLIENT_NO_AUTH_WITHOUT_CONFIG"})
433+
@patch("airflowctl.api.client.keyring")
434+
def test_get_client_no_auth_without_config(mock_keyring):
435+
with get_client(kind=ClientKind.NO_AUTH) as client:
436+
assert str(client.base_url) == "http://localhost:8080/api/v2/"
437+
438+
mock_keyring.get_password.assert_not_called()
439+
440+
403441
@pytest.mark.parametrize("api_environment", ["../evil", "..\\evil", "a/b", "a\\b"])
404442
def test_credentials_rejects_unsafe_env_argument(api_environment):
405443
with pytest.raises(AirflowCtlException, match="environment"):

airflow-ctl/tests/airflow_ctl/ctl/commands/test_version_command.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
import pytest
2424

25-
from airflowctl.api.client import Client
25+
from airflowctl.api.client import Client, ClientKind
2626
from airflowctl.ctl import cli_parser
2727
from airflowctl.ctl.commands.version_command import version_info
2828

@@ -52,6 +52,13 @@ def test_ctl_version_remote(self, mock_client):
5252
assert "version" in stdout.getvalue()
5353
assert "git_version" in stdout.getvalue()
5454
assert "airflowctl_version" in stdout.getvalue()
55+
mock_get_client.assert_called_once_with(kind=ClientKind.NO_AUTH)
56+
57+
def test_ctl_version_remote_with_api_token(self, mock_client):
58+
with mock.patch("airflowctl.ctl.commands.version_command.get_client") as mock_get_client:
59+
mock_get_client.return_value.__enter__.return_value = mock_client
60+
version_info(self.parser.parse_args(["version", "--remote", "--api-token", "TOKEN"]))
61+
mock_get_client.assert_called_once_with(kind=ClientKind.NO_AUTH)
5562

5663
def test_ctl_version_only_local_version(self, mock_client):
5764
"""Test the version command without --remote does not touch credentials."""

0 commit comments

Comments
 (0)