This is an automated email from the ASF dual-hosted git repository.
henry3260 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 84719cfb009 Fix airflowctl tracebacks on an unusable credentials file
(#72468)
84719cfb009 is described below
commit 84719cfb009f6286cdbb2883ba9615955dd14e17
Author: Y-C <[email protected]>
AuthorDate: Sun Nov 1 19:06:37 2026 +0800
Fix airflowctl tracebacks on an unusable credentials file (#72468)
---
airflow-ctl/src/airflowctl/api/client.py | 124 +++++++++++++--------
airflow-ctl/src/airflowctl/ctl/cli_config.py | 15 +--
.../src/airflowctl/ctl/commands/auth_command.py | 10 +-
airflow-ctl/src/airflowctl/exceptions.py | 4 +
airflow-ctl/tests/airflow_ctl/api/test_client.py | 52 ++++++++-
.../airflow_ctl/ctl/commands/test_auth_command.py | 22 ++++
.../tests/airflow_ctl/ctl/test_cli_config.py | 10 +-
7 files changed, 174 insertions(+), 63 deletions(-)
diff --git a/airflow-ctl/src/airflowctl/api/client.py
b/airflow-ctl/src/airflowctl/api/client.py
index 99a506b9cb2..b692c2f2e2d 100644
--- a/airflow-ctl/src/airflowctl/api/client.py
+++ b/airflow-ctl/src/airflowctl/api/client.py
@@ -64,6 +64,7 @@ from airflowctl.api.operations import (
XComOperations,
)
from airflowctl.exceptions import (
+ AirflowCtlCredentialInvalidException,
AirflowCtlCredentialNotFoundException,
AirflowCtlException,
AirflowCtlKeyringException,
@@ -162,6 +163,41 @@ def _safe_path_under_airflow_home(airflow_home: str,
filename: str) -> str:
return str(target)
+def _read_cli_config(path: str) -> dict:
+ """Read a credentials file, so an unusable one reaches the user as a
message and not a traceback."""
+ try:
+ with open(path, encoding="utf-8") as f:
+ config = json.load(f)
+ except (FileNotFoundError, json.JSONDecodeError):
+ # Callers already treat an absent file and malformed JSON as missing
credentials.
+ raise
+ except OSError as e:
+ # `auth login` writes to the same path, so logging in again cannot fix
this.
+ raise AirflowCtlCredentialInvalidException(
+ f"Credentials file {path} could not be read: {e}. Check its
permissions."
+ ) from e
+ except ValueError as e:
+ # Non-UTF-8 bytes; malformed JSON was re-raised above.
+ raise AirflowCtlCredentialInvalidException(
+ f"Credentials file {path} could not be read: {e}. Log in again to
recreate it."
+ ) from e
+ if not isinstance(config, dict):
+ raise AirflowCtlCredentialInvalidException(
+ f"Credentials file {path} is not a JSON object. Log in again to
recreate it."
+ )
+ return config
+
+
+def _read_api_url(path: str) -> str:
+ """Read the API URL from a credentials file; the debug-credentials file
has none, so it is not in _read_cli_config."""
+ api_url = _read_cli_config(path).get("api_url")
+ if not isinstance(api_url, str):
+ raise AirflowCtlCredentialInvalidException(
+ f"Credentials file {path} does not contain an api_url. Log in
again to recreate it."
+ )
+ return api_url
+
+
# Credentials for the API
class Credentials:
"""Credentials for the API."""
@@ -253,57 +289,57 @@ class Credentials:
default_config_dir = os.environ.get("AIRFLOW_HOME",
os.path.expanduser("~/airflow"))
config_path = _safe_path_under_airflow_home(default_config_dir,
self.input_cli_config_file)
try:
- with open(config_path) as f:
- credentials = json.load(f)
- self.api_url = credentials["api_url"]
- if self.client_kind == ClientKind.NO_AUTH:
- return self
- if self.api_token is not None:
- return self
- if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
- debug_creds_path = _safe_path_under_airflow_home(
- default_config_dir,
f"debug_creds_{self.input_cli_config_file}"
+ self.api_url = _read_api_url(config_path)
+ if self.client_kind == ClientKind.NO_AUTH:
+ return self
+ if self.api_token is not None:
+ return self
+ if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
+ debug_creds_path = _safe_path_under_airflow_home(
+ default_config_dir,
f"debug_creds_{self.input_cli_config_file}"
+ )
+ try:
+ debug_credentials = _read_cli_config(debug_creds_path)
+ self.api_token = debug_credentials.get(
+ self.token_key_for_environment(self.api_environment)
+ )
+ except FileNotFoundError as e:
+ if self.client_kind == ClientKind.CLI:
+ raise AirflowCtlCredentialNotFoundException(
+ f"Debug credentials file not found:
{debug_creds_path}. "
+ "Set AIRFLOW_CLI_DEBUG_MODE=false or log in with
debug mode enabled first."
+ ) from e
+ self.api_token = None
+ else:
+ try:
+ self.api_token = keyring.get_password(
+ "airflowctl",
self.token_key_for_environment(self.api_environment)
)
- try:
- with open(debug_creds_path) as df:
- debug_credentials = json.load(df)
- self.api_token = debug_credentials.get(
-
self.token_key_for_environment(self.api_environment)
- )
- except FileNotFoundError as e:
- if self.client_kind == ClientKind.CLI:
- raise AirflowCtlCredentialNotFoundException(
- f"Debug credentials file not found:
{debug_creds_path}. "
- "Set AIRFLOW_CLI_DEBUG_MODE=false or log in
with debug mode enabled first."
- ) from e
- self.api_token = None
- else:
- try:
- self.api_token = keyring.get_password(
- "airflowctl",
self.token_key_for_environment(self.api_environment)
- )
- except ValueError as e:
- # Incorrect keyring password
- log.warning(
- "Could not access keyring for environment %s: %s",
self.api_environment, e
- )
- if self.client_kind == ClientKind.CLI:
- raise AirflowCtlKeyringException(
- f"Incorrect keyring password for environment
{self.api_environment}"
- ) from e
- self.api_token = None
- except NoKeyringError as e:
- # No keyring backend available
- log.error("No keyring backend available: %s", e)
- if self.client_kind == ClientKind.CLI:
- raise AirflowCtlKeyringException("Keyring backend
is not available") from e
- self.api_token = None
+ except ValueError as e:
+ # Incorrect keyring password
+ log.warning("Could not access keyring for environment %s:
%s", self.api_environment, e)
+ if self.client_kind == ClientKind.CLI:
+ raise AirflowCtlKeyringException(
+ f"Incorrect keyring password for environment
{self.api_environment}"
+ ) from e
+ self.api_token = None
+ except NoKeyringError as e:
+ # No keyring backend available
+ log.error("No keyring backend available: %s", e)
+ if self.client_kind == ClientKind.CLI:
+ raise AirflowCtlKeyringException("Keyring backend is
not available") from e
+ self.api_token = None
except (FileNotFoundError, json.JSONDecodeError):
# This is expected during the auth login command.
# Also allow token-only usage without local config (for commands
like `version --remote`).
# JSONDecodeError handles the case where the credentials file
exists but is empty or corrupted.
if self.client_kind not in (ClientKind.AUTH, ClientKind.NO_AUTH)
and self.api_token is None:
raise AirflowCtlCredentialNotFoundException("No credentials
file found. Please login first.")
+ except AirflowCtlCredentialInvalidException:
+ # `auth login` has to be able to replace an unusable file, and it
is the recovery the
+ # message recommends. Every other kind would silently fall back to
the default API URL.
+ if self.client_kind != ClientKind.AUTH:
+ raise
return self
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 455454fc271..25d5d6c870f 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -46,12 +46,7 @@ from airflowctl.api.operations import BaseOperations,
ServerResponseError
from airflowctl.ctl.console_formatting import AirflowConsole, is_data_sequence
from airflowctl.ctl.utils.validation import split_validation_errors
from airflowctl.ctl.utils.yaml import safe_load
-from airflowctl.exceptions import (
- AirflowCtlConnectionException,
- AirflowCtlCredentialNotFoundException,
- AirflowCtlKeyringException,
- AirflowCtlNotFoundException,
-)
+from airflowctl.exceptions import AirflowCtlException
from airflowctl.utils.module_loading import import_string
BUILD_DOCS = "BUILDING_AIRFLOW_DOCS" in os.environ
@@ -98,12 +93,8 @@ def safe_call_command(function: Callable, args:
Iterable[Arg]) -> None:
try:
function(args)
- except (
- AirflowCtlCredentialNotFoundException,
- AirflowCtlConnectionException,
- AirflowCtlKeyringException,
- AirflowCtlNotFoundException,
- ) as e:
+ except AirflowCtlException as e:
+ # The base class, so a new AirflowCtl error never regresses to a raw
traceback.
rich.print(f"command failed due to {e}")
sys.exit(1)
except (httpx.RemoteProtocolError, httpx.ReadError, httpx.WriteError) as e:
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
index a2e87b307d2..9cbe48e9f81 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
@@ -32,11 +32,13 @@ from airflowctl.api.client import (
NEW_API_CLIENT,
ClientKind,
Credentials,
+ _read_api_url,
get_default_api_url,
provide_api_client,
)
from airflowctl.api.datamodels.auth_generated import LoginBody
from airflowctl.ctl.console_formatting import AirflowConsole
+from airflowctl.exceptions import AirflowCtlCredentialInvalidException
@provide_api_client(kind=ClientKind.AUTH)
@@ -187,11 +189,9 @@ def list_envs(args) -> None:
config_status = "ok"
try:
- with open(config_path) as f:
- config = json.load(f)
- api_url = config.get("api_url", "unknown")
- except (OSError, json.JSONDecodeError, KeyError) as e:
- config_status = f"config error: {str(e)[:50]}"
+ api_url = _read_api_url(config_path)
+ except (OSError, ValueError, AirflowCtlCredentialInvalidException) as
e:
+ config_status = f"config error: {e}"
api_url = "error reading config"
# Try to get token from keyring
diff --git a/airflow-ctl/src/airflowctl/exceptions.py
b/airflow-ctl/src/airflowctl/exceptions.py
index 0af8b9fa593..dc52d09ca52 100644
--- a/airflow-ctl/src/airflowctl/exceptions.py
+++ b/airflow-ctl/src/airflowctl/exceptions.py
@@ -38,6 +38,10 @@ class
AirflowCtlCredentialNotFoundException(AirflowCtlNotFoundException):
"""Raise when a credential couldn't be found while performing an
operation."""
+class AirflowCtlCredentialInvalidException(AirflowCtlException):
+ """Raise when a credentials file exists but cannot be used."""
+
+
class AirflowCtlConnectionException(AirflowCtlException):
"""Raise when a connection error occurs while performing an operation."""
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py
b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index 511428c2135..17d1f634888 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -50,6 +50,7 @@ from airflowctl.api.client import (
)
from airflowctl.api.operations import ServerResponseError
from airflowctl.exceptions import (
+ AirflowCtlCredentialInvalidException,
AirflowCtlCredentialNotFoundException,
AirflowCtlException,
AirflowCtlKeyringException,
@@ -381,7 +382,7 @@ class TestCredentials:
mock_keyring.set_password = MagicMock()
mock_keyring.get_password = MagicMock()
- Credentials(client_kind=cli_client).save(skip_keyring=True)
+ Credentials(api_url="http://localhost:8080",
client_kind=cli_client).save(skip_keyring=True)
config_dir = os.environ.get("AIRFLOW_HOME",
os.path.expanduser("~/airflow"))
assert os.path.exists(config_dir)
@@ -508,6 +509,55 @@ class TestCredentials:
credentials = Credentials(client_kind=cli_client,
api_token="TEST_TOKEN").load()
assert credentials.api_token == "TEST_TOKEN"
+ @pytest.mark.parametrize(
+ ("contents", "mode", "expected"),
+ [
+ pytest.param(b"\xff\xfe", None, "could not be read.*Log in again",
id="not-utf8"),
+ pytest.param(
+ b'{"api_url": "http://x"}', 0o000, "could not be read.*Check
its permissions", id="unreadable"
+ ),
+ pytest.param(json.dumps(["a"]).encode(), None, "is not a JSON
object", id="not-an-object"),
+ pytest.param(
+ json.dumps({"other": 1}).encode(), None, "does not contain an
api_url", id="no-api-url"
+ ),
+ pytest.param(
+ json.dumps({"api_url": None}).encode(), None, "does not
contain an api_url", id="null-api-url"
+ ),
+ ],
+ )
+ @pytest.mark.parametrize("client_kind", [ClientKind.CLI,
ClientKind.NO_AUTH])
+ def test_load_unusable_credentials_file_reports_an_error(
+ self, monkeypatch, tmp_path, contents, mode, expected, client_kind
+ ):
+ """A file airflowctl cannot use must reach the user as a message, not
a traceback."""
+ monkeypatch.setenv("AIRFLOW_HOME", str(tmp_path))
+ monkeypatch.setenv("AIRFLOW_CLI_ENVIRONMENT", "TEST_BAD_CONFIG")
+ config = tmp_path / "TEST_BAD_CONFIG.json"
+ config.write_bytes(contents)
+ if mode is not None:
+ config.chmod(mode)
+
+ with pytest.raises(AirflowCtlCredentialInvalidException,
match=expected):
+ Credentials(client_kind=client_kind).load()
+
+ def test_load_unusable_debug_credentials_file_reports_an_error(self,
monkeypatch, tmp_path):
+ monkeypatch.setenv("AIRFLOW_HOME", str(tmp_path))
+ monkeypatch.setenv("AIRFLOW_CLI_ENVIRONMENT", "TEST_BAD_CONFIG")
+ monkeypatch.setenv("AIRFLOW_CLI_DEBUG_MODE", "true")
+ (tmp_path / "TEST_BAD_CONFIG.json").write_text(json.dumps({"api_url":
"http://x"}), encoding="utf-8")
+ (tmp_path /
"debug_creds_TEST_BAD_CONFIG.json").write_text(json.dumps(["a"]),
encoding="utf-8")
+
+ with pytest.raises(AirflowCtlCredentialInvalidException, match="is not
a JSON object"):
+ Credentials(client_kind=ClientKind.CLI).load()
+
+ def test_load_tolerates_an_unusable_file_for_auth_login(self, monkeypatch,
tmp_path):
+ """`auth login` is the recovery the error recommends, so it must
survive an unusable file."""
+ monkeypatch.setenv("AIRFLOW_HOME", str(tmp_path))
+ monkeypatch.setenv("AIRFLOW_CLI_ENVIRONMENT", "TEST_BAD_CONFIG")
+ (tmp_path / "TEST_BAD_CONFIG.json").write_text(json.dumps({"other":
1}), encoding="utf-8")
+
+ assert Credentials(client_kind=ClientKind.AUTH).load().api_url is None
+
class TestBoundedGetNewPassword:
@patch("airflowctl.api.client.getpass.getpass")
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 9cd7cf8d16d..d7331e6975b 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
@@ -546,6 +546,28 @@ class TestListEnvs:
args = self.parser.parse_args(["auth", "list-envs"])
auth_command.list_envs(args)
+ @pytest.mark.parametrize(
+ ("contents", "expected"),
+ [
+ pytest.param(b"\xff\xfe", "codec can't decode", id="not-utf8"),
+ pytest.param(b"[]", "not a JSON object", id="not-an-object"),
+ pytest.param(b'{"api_url": null}', "does not contain an api_url",
id="null-api-url"),
+ ],
+ )
+ @patch("airflowctl.ctl.commands.auth_command.AirflowConsole",
autospec=True)
+ @patch("keyring.get_password")
+ def test_list_envs_unusable_config(
+ self, mock_get_password, mock_console, monkeypatch, tmp_path,
contents, expected
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", str(tmp_path))
+ (tmp_path / "production.json").write_bytes(contents)
+
+ auth_command.list_envs(self.parser.parse_args(["auth", "list-envs"]))
+
+ [env] = mock_console.return_value.print_as.call_args.kwargs["data"]
+ assert env["api_url"] == "error reading config"
+ assert expected in env["status"]
+
def test_list_envs_debug_mode(self, monkeypatch):
"""Test list-envs in debug mode."""
with tempfile.TemporaryDirectory() as temp_airflow_home:
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
index 3d09bef569e..961a41c8c77 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -70,6 +70,7 @@ from airflowctl.ctl.console_formatting import AirflowConsole
from airflowctl.ctl.utils.yaml import safe_load
from airflowctl.exceptions import (
AirflowCtlConnectionException,
+ AirflowCtlCredentialInvalidException,
AirflowCtlCredentialNotFoundException,
AirflowCtlKeyringException,
AirflowCtlNotFoundException,
@@ -722,11 +723,18 @@ class TestCliConfigMethods:
"raised_exception",
[
AirflowCtlCredentialNotFoundException("missing credentials"),
+ AirflowCtlCredentialInvalidException("unusable credentials"),
AirflowCtlConnectionException("connection failed"),
AirflowCtlKeyringException("keyring failure"),
AirflowCtlNotFoundException("resource not found"),
],
- ids=["credential-not-found", "connection-error", "keyring-error",
"not-found"],
+ ids=[
+ "credential-not-found",
+ "credential-invalid",
+ "connection-error",
+ "keyring-error",
+ "not-found",
+ ],
)
def test_safe_call_command_exits_non_zero_for_airflowctl_exceptions(self,
raised_exception):
def raise_error(_args):