This is an automated email from the ASF dual-hosted git repository.
potiuk 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 a7b79ca1cf9 Add auth list-envs command to list CLI environments and
their auth status (#61426)
a7b79ca1cf9 is described below
commit a7b79ca1cf96cc240f90eec124c6467d4b833052
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Sat Feb 14 18:05:18 2026 -0600
Add auth list-envs command to list CLI environments and their auth status
(#61426)
- Adds a new 'airflowctl auth list-envs' command that scans AIRFLOW_HOME
for environment config files and reports each environment's
name, API URL, and authentication status (authenticated / not
authenticated).
- Filters out internal files (debug_creds_*.json, *_generated.json) so
only real environments are listed.
---
airflow-ctl/docs/images/command_hashes.txt | 2 +-
airflow-ctl/docs/images/output_auth.svg | 100 ++++-----
airflow-ctl/src/airflowctl/ctl/cli_config.py | 7 +
.../src/airflowctl/ctl/commands/auth_command.py | 89 ++++++++
.../airflow_ctl/ctl/commands/test_auth_command.py | 223 +++++++++++++++++++++
5 files changed, 374 insertions(+), 47 deletions(-)
diff --git a/airflow-ctl/docs/images/command_hashes.txt
b/airflow-ctl/docs/images/command_hashes.txt
index 8a450901218..7f990f14b1b 100644
--- a/airflow-ctl/docs/images/command_hashes.txt
+++ b/airflow-ctl/docs/images/command_hashes.txt
@@ -1,6 +1,6 @@
main:65249416abad6ad24c276fb44326ae15
assets:b3ae2b933e54528bf486ff28e887804d
-auth:f396d4bce90215599dde6ad0a8f30f29
+auth:82bc73405e153df5112f05c4811ab92b
backfill:bbce9859a2d1ce054ad22db92dea8c05
config:cb175bedf29e8a2c2c6a2ebd13d770a7
connections:e34b6b93f64714986139958c1f370428
diff --git a/airflow-ctl/docs/images/output_auth.svg
b/airflow-ctl/docs/images/output_auth.svg
index fc7f38acd3f..9f0f482e186 100644
--- a/airflow-ctl/docs/images/output_auth.svg
+++ b/airflow-ctl/docs/images/output_auth.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 811 342.79999999999995"
xmlns="http://www.w3.org/2000/svg">
+<svg class="rich-terminal" viewBox="0 0 933 391.59999999999997"
xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@@ -19,86 +19,94 @@
font-weight: 700;
}
- .terminal-1537035387-matrix {
+ .terminal-1800249333-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1537035387-title {
+ .terminal-1800249333-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1537035387-r1 { fill: #ff8700 }
-.terminal-1537035387-r2 { fill: #c5c8c6 }
-.terminal-1537035387-r3 { fill: #808080 }
-.terminal-1537035387-r4 { fill: #68a0b3 }
+ .terminal-1800249333-r1 { fill: #ff8700 }
+.terminal-1800249333-r2 { fill: #c5c8c6 }
+.terminal-1800249333-r3 { fill: #808080 }
+.terminal-1800249333-r4 { fill: #68a0b3 }
</style>
<defs>
- <clipPath id="terminal-1537035387-clip-terminal">
- <rect x="0" y="0" width="792.0" height="291.79999999999995" />
+ <clipPath id="terminal-1800249333-clip-terminal">
+ <rect x="0" y="0" width="914.0" height="340.59999999999997" />
</clipPath>
- <clipPath id="terminal-1537035387-line-0">
- <rect x="0" y="1.5" width="793" height="24.65"/>
+ <clipPath id="terminal-1800249333-line-0">
+ <rect x="0" y="1.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-1">
- <rect x="0" y="25.9" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-1">
+ <rect x="0" y="25.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-2">
- <rect x="0" y="50.3" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-2">
+ <rect x="0" y="50.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-3">
- <rect x="0" y="74.7" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-3">
+ <rect x="0" y="74.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-4">
- <rect x="0" y="99.1" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-4">
+ <rect x="0" y="99.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-5">
- <rect x="0" y="123.5" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-5">
+ <rect x="0" y="123.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-6">
- <rect x="0" y="147.9" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-6">
+ <rect x="0" y="147.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-7">
- <rect x="0" y="172.3" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-7">
+ <rect x="0" y="172.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-8">
- <rect x="0" y="196.7" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-8">
+ <rect x="0" y="196.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-9">
- <rect x="0" y="221.1" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-9">
+ <rect x="0" y="221.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-1537035387-line-10">
- <rect x="0" y="245.5" width="793" height="24.65"/>
+<clipPath id="terminal-1800249333-line-10">
+ <rect x="0" y="245.5" width="915" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1800249333-line-11">
+ <rect x="0" y="269.9" width="915" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1800249333-line-12">
+ <rect x="0" y="294.3" width="915" height="24.65"/>
</clipPath>
</defs>
- <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1"
x="1" y="1" width="809" height="340.8" rx="8"/>
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1"
x="1" y="1" width="931" height="389.6" rx="8"/>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
- <g transform="translate(9, 41)"
clip-path="url(#terminal-1537035387-clip-terminal)">
+ <g transform="translate(9, 41)"
clip-path="url(#terminal-1800249333-clip-terminal)">
- <g class="terminal-1537035387-matrix">
- <text class="terminal-1537035387-r1" x="0" y="20" textLength="73.2"
clip-path="url(#terminal-1537035387-line-0)">Usage:</text><text
class="terminal-1537035387-r3" x="85.4" y="20" textLength="183"
clip-path="url(#terminal-1537035387-line-0)">airflowctl auth</text><text
class="terminal-1537035387-r2" x="268.4" y="20" textLength="24.4"
clip-path="url(#terminal-1537035387-line-0)"> [</text><text
class="terminal-1537035387-r4" x="292.8" y="20" textLength="24.4"
clip-path="url(#t [...]
-</text><text class="terminal-1537035387-r2" x="793" y="44.4" textLength="12.2"
clip-path="url(#terminal-1537035387-line-1)">
-</text><text class="terminal-1537035387-r2" x="0" y="68.8" textLength="793"
clip-path="url(#terminal-1537035387-line-2)">Manage authentication for CLI. Either pass token from environment</text><text
class="terminal-1537035387-r2" x="793" y="68.8" textLength="12.2"
clip-path="url(#terminal-1537035387-line-2)">
-</text><text class="terminal-1537035387-r2" x="0" y="93.2" textLength="597.8"
clip-path="url(#terminal-1537035387-line-3)">variable/parameter or pass username and password.</text><text
class="terminal-1537035387-r2" x="793" y="93.2" textLength="12.2"
clip-path="url(#terminal-1537035387-line-3)">
-</text><text class="terminal-1537035387-r2" x="793" y="117.6"
textLength="12.2" clip-path="url(#terminal-1537035387-line-4)">
-</text><text class="terminal-1537035387-r1" x="0" y="142" textLength="256.2"
clip-path="url(#terminal-1537035387-line-5)">Positional Arguments:</text><text
class="terminal-1537035387-r2" x="793" y="142" textLength="12.2"
clip-path="url(#terminal-1537035387-line-5)">
-</text><text class="terminal-1537035387-r4" x="24.4" y="166.4"
textLength="85.4"
clip-path="url(#terminal-1537035387-line-6)">COMMAND</text><text
class="terminal-1537035387-r2" x="793" y="166.4" textLength="12.2"
clip-path="url(#terminal-1537035387-line-6)">
-</text><text class="terminal-1537035387-r4" x="48.8" y="190.8" textLength="61"
clip-path="url(#terminal-1537035387-line-7)">login</text><text
class="terminal-1537035387-r2" x="170.8" y="190.8" textLength="622.2"
clip-path="url(#terminal-1537035387-line-7)">Login to the metadata database for personal usage. </text><text
class="terminal-1537035387-r2" x="793" y="190.8" textLength="12.2"
clip-path="url(#terminal-1537035387-line-7)">
-</text><text class="terminal-1537035387-r2" x="0" y="215.2" textLength="500.2"
clip-path="url(#terminal-1537035387-line-8)">JWT Token must be provided via parameter.</text><text
class="terminal-1537035387-r2" x="793" y="215.2" textLength="12.2"
clip-path="url(#terminal-1537035387-line-8)">
-</text><text class="terminal-1537035387-r2" x="793" y="239.6"
textLength="12.2" clip-path="url(#terminal-1537035387-line-9)">
-</text><text class="terminal-1537035387-r1" x="0" y="264" textLength="97.6"
clip-path="url(#terminal-1537035387-line-10)">Options:</text><text
class="terminal-1537035387-r2" x="793" y="264" textLength="12.2"
clip-path="url(#terminal-1537035387-line-10)">
-</text><text class="terminal-1537035387-r4" x="24.4" y="288.4"
textLength="24.4" clip-path="url(#terminal-1537035387-line-11)">-h</text><text
class="terminal-1537035387-r2" x="48.8" y="288.4" textLength="24.4"
clip-path="url(#terminal-1537035387-line-11)">, </text><text
class="terminal-1537035387-r4" x="73.2" y="288.4" textLength="73.2"
clip-path="url(#terminal-1537035387-line-11)">--help</text><text
class="terminal-1537035387-r2" x="170.8" y="288.4" textLength="378.2"
clip-path="ur [...]
+ <g class="terminal-1800249333-matrix">
+ <text class="terminal-1800249333-r1" x="0" y="20" textLength="73.2"
clip-path="url(#terminal-1800249333-line-0)">Usage:</text><text
class="terminal-1800249333-r3" x="85.4" y="20" textLength="183"
clip-path="url(#terminal-1800249333-line-0)">airflowctl auth</text><text
class="terminal-1800249333-r2" x="268.4" y="20" textLength="24.4"
clip-path="url(#terminal-1800249333-line-0)"> [</text><text
class="terminal-1800249333-r4" x="292.8" y="20" textLength="24.4"
clip-path="url(#t [...]
+</text><text class="terminal-1800249333-r2" x="915" y="44.4" textLength="12.2"
clip-path="url(#terminal-1800249333-line-1)">
+</text><text class="terminal-1800249333-r2" x="0" y="68.8" textLength="805.2"
clip-path="url(#terminal-1800249333-line-2)">Manage authentication for CLI. Either pass token from environment </text><text
class="terminal-1800249333-r2" x="915" y="68.8" textLength="12.2"
clip-path="url(#terminal-1800249333-line-2)">
+</text><text class="terminal-1800249333-r2" x="0" y="93.2" textLength="597.8"
clip-path="url(#terminal-1800249333-line-3)">variable/parameter or pass username and password.</text><text
class="terminal-1800249333-r2" x="915" y="93.2" textLength="12.2"
clip-path="url(#terminal-1800249333-line-3)">
+</text><text class="terminal-1800249333-r2" x="915" y="117.6"
textLength="12.2" clip-path="url(#terminal-1800249333-line-4)">
+</text><text class="terminal-1800249333-r1" x="0" y="142" textLength="256.2"
clip-path="url(#terminal-1800249333-line-5)">Positional Arguments:</text><text
class="terminal-1800249333-r2" x="915" y="142" textLength="12.2"
clip-path="url(#terminal-1800249333-line-5)">
+</text><text class="terminal-1800249333-r4" x="24.4" y="166.4"
textLength="85.4"
clip-path="url(#terminal-1800249333-line-6)">COMMAND</text><text
class="terminal-1800249333-r2" x="915" y="166.4" textLength="12.2"
clip-path="url(#terminal-1800249333-line-6)">
+</text><text class="terminal-1800249333-r4" x="48.8" y="190.8"
textLength="109.8"
clip-path="url(#terminal-1800249333-line-7)">list-envs</text><text
class="terminal-1800249333-r2" x="915" y="190.8" textLength="12.2"
clip-path="url(#terminal-1800249333-line-7)">
+</text><text class="terminal-1800249333-r2" x="170.8" y="215.2"
textLength="671"
clip-path="url(#terminal-1800249333-line-8)">List all CLI environments that the user has logged into</text><text
class="terminal-1800249333-r2" x="915" y="215.2" textLength="12.2"
clip-path="url(#terminal-1800249333-line-8)">
+</text><text class="terminal-1800249333-r4" x="48.8" y="239.6" textLength="61"
clip-path="url(#terminal-1800249333-line-9)">login</text><text
class="terminal-1800249333-r2" x="170.8" y="239.6" textLength="744.2"
clip-path="url(#terminal-1800249333-line-9)">Login to the metadata database for personal usage. JWT Token </text><text
class="terminal-1800249333-r2" x="915" y="239.6" textLength="12.2"
clip-path="url(#terminal-1800249333-line-9)">
+</text><text class="terminal-1800249333-r2" x="0" y="264" textLength="378.2"
clip-path="url(#terminal-1800249333-line-10)">must be provided via parameter.</text><text
class="terminal-1800249333-r2" x="915" y="264" textLength="12.2"
clip-path="url(#terminal-1800249333-line-10)">
+</text><text class="terminal-1800249333-r2" x="915" y="288.4"
textLength="12.2" clip-path="url(#terminal-1800249333-line-11)">
+</text><text class="terminal-1800249333-r1" x="0" y="312.8" textLength="97.6"
clip-path="url(#terminal-1800249333-line-12)">Options:</text><text
class="terminal-1800249333-r2" x="915" y="312.8" textLength="12.2"
clip-path="url(#terminal-1800249333-line-12)">
+</text><text class="terminal-1800249333-r4" x="24.4" y="337.2"
textLength="24.4" clip-path="url(#terminal-1800249333-line-13)">-h</text><text
class="terminal-1800249333-r2" x="48.8" y="337.2" textLength="24.4"
clip-path="url(#terminal-1800249333-line-13)">, </text><text
class="terminal-1800249333-r4" x="73.2" y="337.2" textLength="73.2"
clip-path="url(#terminal-1800249333-line-13)">--help</text><text
class="terminal-1800249333-r2" x="170.8" y="337.2" textLength="378.2"
clip-path="ur [...]
</text>
</g>
</g>
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 76b7bec3ba6..2b8e6d8bfd7 100644
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -811,6 +811,13 @@ AUTH_COMMANDS = (
func=lazy_load_command("airflowctl.ctl.commands.auth_command.login"),
args=(ARG_AUTH_URL, ARG_AUTH_TOKEN, ARG_AUTH_ENVIRONMENT,
ARG_AUTH_USERNAME, ARG_AUTH_PASSWORD),
),
+ ActionCommand(
+ name="list-envs",
+ help="List all CLI environments that the user has logged into",
+ description="List all CLI environments with their authentication
status",
+
func=lazy_load_command("airflowctl.ctl.commands.auth_command.list_envs"),
+ args=(ARG_OUTPUT,),
+ ),
)
CONFIG_COMMANDS = (
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
index e66a1d835d4..8cc55ff69e8 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
@@ -18,13 +18,18 @@
from __future__ import annotations
+import glob
+import json
import os
import sys
+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.datamodels.auth_generated import LoginBody
+from airflowctl.ctl.console_formatting import AirflowConsole
@provide_api_client(kind=ClientKind.AUTH)
@@ -75,3 +80,87 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
api_environment=args.env,
).save()
rich.print(success_message)
+
+
+def list_envs(args) -> None:
+ """List all CLI environments that the user has logged into."""
+ # Get AIRFLOW_HOME
+ airflow_home = os.environ.get("AIRFLOW_HOME",
os.path.expanduser("~/airflow"))
+
+ # Check if directory exists
+ if not os.path.isdir(airflow_home):
+ rich.print(f"[yellow]No AIRFLOW_HOME directory found at
{airflow_home}[/yellow]")
+ AirflowConsole().print_as(data=[], output=args.output)
+ return
+
+ # Find all .json files
+ config_files = glob.glob(os.path.join(airflow_home, "*.json"))
+
+ environments = []
+
+ for config_path in config_files:
+ filename = os.path.basename(config_path)
+
+ # Skip non-environment config files
+ if filename.startswith("debug_creds_") or
filename.endswith("_generated.json"):
+ continue
+
+ env_name = filename.replace(".json", "")
+
+ # Try to read config file
+ api_url = 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 = "error reading config"
+
+ # Try to get token from keyring
+ token_status = "not authenticated"
+
+ try:
+ if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
+ # Check debug credentials file
+ debug_path = os.path.join(airflow_home,
f"debug_creds_{env_name}.json")
+ if os.path.exists(debug_path):
+ with open(debug_path) as f:
+ debug_creds = json.load(f)
+ if f"api_token_{env_name}" in debug_creds:
+ token_status = "authenticated"
+ else:
+ # Check keyring
+ token = keyring.get_password("airflowctl",
f"api_token_{env_name}")
+ if token:
+ token_status = "authenticated"
+ except NoKeyringError:
+ token_status = "keyring unavailable"
+ except ValueError:
+ # Incorrect keyring password
+ token_status = "keyring error"
+ except Exception as e:
+ token_status = f"error: {str(e)[:30]}"
+
+ # If config is corrupted, override token status
+ if config_status != "ok":
+ token_status = config_status
+
+ environments.append(
+ {
+ "environment": env_name,
+ "api_url": api_url,
+ "status": token_status,
+ }
+ )
+
+ # Sort by environment name
+ environments.sort(key=lambda x: x.get("environment", ""))
+
+ # Display results
+ if not environments:
+ rich.print(f"[yellow]No environments found in {airflow_home}[/yellow]")
+
+ AirflowConsole().print_as(data=environments, output=args.output)
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 b0faa0965ca..54fb83901ee 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
@@ -139,3 +139,226 @@ class TestCliAuthCommands:
),
api_client=api_client,
)
+
+
+class TestListEnvs:
+ parser = cli_parser.get_parser()
+
+ def test_list_envs_empty_airflow_home(self, monkeypatch):
+ """Test list-envs with no AIRFLOW_HOME directory."""
+ with (
+ tempfile.TemporaryDirectory() as temp_dir,
+ patch("keyring.get_password"),
+ ):
+ non_existent_dir = os.path.join(temp_dir, "non_existent")
+ monkeypatch.setenv("AIRFLOW_HOME", non_existent_dir)
+
+ args = self.parser.parse_args(["auth", "list-envs"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_no_environments(self, monkeypatch):
+ """Test list-envs with empty AIRFLOW_HOME."""
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password"),
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ args = self.parser.parse_args(["auth", "list-envs"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_single_authenticated(self, monkeypatch):
+ """Test list-envs with a single authenticated environment."""
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password") as mock_get_password,
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ # Create a config file
+ config_path = os.path.join(temp_airflow_home, "production.json")
+ with open(config_path, "w") as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+
+ # Mock keyring to return a token
+ mock_get_password.return_value = "test_token"
+
+ args = self.parser.parse_args(["auth", "list-envs"])
+ auth_command.list_envs(args)
+
+ mock_get_password.assert_called_once_with("airflowctl",
"api_token_production")
+
+ def test_list_envs_multiple_mixed_status(self, monkeypatch):
+ """Test list-envs with multiple environments with different
statuses."""
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password") as mock_get_password,
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ # Create authenticated environment
+ with open(os.path.join(temp_airflow_home, "production.json"), "w")
as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+
+ # Create not authenticated environment
+ with open(os.path.join(temp_airflow_home, "staging.json"), "w") as
f:
+ json.dump({"api_url": "http://localhost:8081"}, f)
+
+ # Mock keyring to return token only for production
+ def mock_get_password_func(service, key):
+ if key == "api_token_production":
+ return "prod_token"
+ return None
+
+ mock_get_password.side_effect = mock_get_password_func
+
+ args = self.parser.parse_args(["auth", "list-envs"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_json_output(self, monkeypatch):
+ """Test list-envs with JSON output format."""
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password") as mock_get_password,
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ # Create a config file
+ with open(os.path.join(temp_airflow_home, "production.json"), "w")
as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+
+ mock_get_password.return_value = "test_token"
+
+ args = self.parser.parse_args(["auth", "list-envs", "--output",
"json"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_yaml_output(self, monkeypatch):
+ """Test list-envs with YAML output format."""
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password") as mock_get_password,
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ # Create a config file
+ with open(os.path.join(temp_airflow_home, "production.json"), "w")
as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+
+ mock_get_password.return_value = "test_token"
+
+ args = self.parser.parse_args(["auth", "list-envs", "--output",
"yaml"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_plain_output(self, monkeypatch):
+ """Test list-envs with plain output format."""
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password") as mock_get_password,
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ # Create a config file
+ with open(os.path.join(temp_airflow_home, "production.json"), "w")
as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+
+ mock_get_password.return_value = "test_token"
+
+ args = self.parser.parse_args(["auth", "list-envs", "--output",
"plain"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_keyring_unavailable(self, monkeypatch):
+ """Test list-envs when keyring is unavailable."""
+ from keyring.errors import NoKeyringError
+
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password") as mock_get_password,
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ # Create a config file
+ with open(os.path.join(temp_airflow_home, "production.json"), "w")
as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+
+ mock_get_password.side_effect = NoKeyringError("no backend")
+
+ args = self.parser.parse_args(["auth", "list-envs"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_keyring_error(self, monkeypatch):
+ """Test list-envs when keyring has an error."""
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password") as mock_get_password,
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ # Create a config file
+ with open(os.path.join(temp_airflow_home, "production.json"), "w")
as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+
+ mock_get_password.side_effect = ValueError("incorrect password")
+
+ args = self.parser.parse_args(["auth", "list-envs"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_corrupted_config(self, monkeypatch):
+ """Test list-envs with corrupted config file."""
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password"),
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ # Create a corrupted config file
+ config_path = os.path.join(temp_airflow_home, "production.json")
+ with open(config_path, "w") as f:
+ f.write("invalid json content {{{")
+
+ args = self.parser.parse_args(["auth", "list-envs"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_debug_mode(self, monkeypatch):
+ """Test list-envs in debug mode."""
+ with tempfile.TemporaryDirectory() as temp_airflow_home:
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+ monkeypatch.setenv("AIRFLOW_CLI_DEBUG_MODE", "true")
+
+ # Create a config file
+ with open(os.path.join(temp_airflow_home, "production.json"), "w")
as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+
+ # Create debug credentials file
+ debug_creds_path = os.path.join(temp_airflow_home,
"debug_creds_production.json")
+ with open(debug_creds_path, "w") as f:
+ json.dump({"api_token_production": "debug_token"}, f)
+
+ args = self.parser.parse_args(["auth", "list-envs"])
+ auth_command.list_envs(args)
+
+ def test_list_envs_filters_special_files(self, monkeypatch):
+ """Test list-envs filters out special files."""
+ with (
+ tempfile.TemporaryDirectory() as temp_airflow_home,
+ patch("keyring.get_password") as mock_get_password,
+ ):
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ # Create regular config
+ with open(os.path.join(temp_airflow_home, "production.json"), "w")
as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+
+ # Create files that should be filtered out
+ with open(os.path.join(temp_airflow_home,
"debug_creds_production.json"), "w") as f:
+ json.dump({"api_token_production": "token"}, f)
+
+ with open(os.path.join(temp_airflow_home, "some_generated.json"),
"w") as f:
+ json.dump({"data": "generated"}, f)
+
+ mock_get_password.return_value = "test_token"
+
+ args = self.parser.parse_args(["auth", "list-envs"])
+ auth_command.list_envs(args)
+
+ # Only production environment should be checked, not the special
files
+ mock_get_password.assert_called_once_with("airflowctl",
"api_token_production")