This is an automated email from the ASF dual-hosted git repository.
turaga 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 15fce018c86 feat(airflowctl): support on headless environments (#62217)
15fce018c86 is described below
commit 15fce018c86616b3cf5767005bb77c1880e453f3
Author: Augusto Hidalgo <[email protected]>
AuthorDate: Thu Feb 26 18:03:25 2026 +0100
feat(airflowctl): support on headless environments (#62217)
We make all commands use the AIRFLOW_CLI_TOKEN environment variable or
`api-token` flag if provided.
---
.../tests/airflowctl_tests/conftest.py | 22 +++-
.../airflowctl_tests/test_airflowctl_commands.py | 56 +++++++---
.../test_config_sensitive_masking.py | 4 +-
airflow-ctl/README.md | 3 +-
airflow-ctl/docs/howto/index.rst | 12 +-
airflow-ctl/docs/images/command_hashes.txt | 4 +-
airflow-ctl/docs/images/output_auth_login.svg | 122 +++++++++++----------
airflow-ctl/docs/images/output_version.svg | 88 ++++++++-------
airflow-ctl/docs/installation/prerequisites.rst | 6 +-
airflow-ctl/docs/security.rst | 1 +
airflow-ctl/src/airflowctl/api/client.py | 42 ++++---
airflow-ctl/src/airflowctl/ctl/cli_config.py | 34 +++++-
.../src/airflowctl/ctl/commands/auth_command.py | 7 +-
airflow-ctl/tests/airflow_ctl/api/test_client.py | 47 ++++++++
.../airflow_ctl/ctl/commands/test_auth_command.py | 47 +++++++-
.../airflow_ctl/ctl/commands/test_pool_command.py | 14 +--
.../tests/airflow_ctl/ctl/test_cli_config.py | 72 +++++++++++-
17 files changed, 427 insertions(+), 154 deletions(-)
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
b/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
index 9e6ef75b7fa..cbcea44ec36 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
@@ -21,6 +21,7 @@ import subprocess
import sys
import pytest
+import requests
from python_on_whales import DockerClient, docker
from airflowctl_tests import console
@@ -35,16 +36,31 @@ from airflowctl_tests.constants import (
from tests_common.test_utils.fernet import generate_fernet_key_string
[email protected](scope="module")
+def api_token():
+ url = "http://localhost:8080/auth/token"
+ payload = {"username": "airflow", "password": "airflow"}
+ try:
+ response = requests.post(url, json=payload)
+ response.raise_for_status()
+ token = response.json().get("access_token")
+ if not token:
+ raise ValueError("Response did not contain an access_token")
+ return token
+ except requests.exceptions.RequestException as e:
+ pytest.fail(f"Failed to obtain token: {e}")
+
+
@pytest.fixture
def run_command():
"""Fixture that provides a helper to run airflowctl commands."""
- def _run_command(command: str, skip_login: bool = False) -> str:
+ def _run_command(command: str, env_vars: dict, skip_login: bool = False)
-> str:
import os
from subprocess import PIPE, STDOUT, Popen
host_envs = os.environ.copy()
- host_envs["AIRFLOW_CLI_DEBUG_MODE"] = "true"
+ host_envs.update(env_vars)
command_from_config = f"airflowctl {command}"
@@ -231,8 +247,6 @@ def docker_compose_up(tmp_path_factory):
dot_env_file = tmp_dir / ".env"
dot_env_file.write_text(
f"AIRFLOW_UID={os.getuid()}\n"
- # To enable debug mode for airflowctl CLI
- "AIRFLOW_CTL_CLI_DEBUG_MODE=true\n"
# To enable config operations to work
"AIRFLOW__API__EXPOSE_CONFIG=true\n"
)
diff --git
a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
index 228321ac7e8..3f89a7c9698 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
@@ -18,8 +18,6 @@ from __future__ import annotations
import pytest
-from airflowctl_tests.constants import LOGIN_COMMAND
-
def date_param():
import random
@@ -44,11 +42,12 @@ def date_param():
return random_dt.isoformat()
-ONE_DATE_PARAM = date_param()
+# Passing password via command line is insecure but acceptable for testing
purposes
+# Please do not do this in production, it enables possibility of exposing your
credentials
+LOGIN_COMMAND = "auth login --username airflow --password airflow"
+LOGIN_COMMAND_SKIP_KEYRING = "auth login --skip-keyring"
+LOGIN_OUTPUT = "Login successful! Welcome to airflowctl!"
TEST_COMMANDS = [
- # Passing password via command line is insecure but acceptable for testing
purposes
- # Please do not do this in production, it enables possibility of exposing
your credentials
- LOGIN_COMMAND,
# Assets commands
"assets list",
"assets get --asset-id=1",
@@ -80,22 +79,22 @@ TEST_COMMANDS = [
"dags list-version --dag-id=example_bash_operator",
"dags list-warning",
# Order of trigger and pause/unpause is important for test stability
because state checked
- f"dags trigger --dag-id=example_bash_operator
--logical-date={ONE_DATE_PARAM} --run-after={ONE_DATE_PARAM}",
+ "dags trigger --dag-id=example_bash_operator --logical-date={date_param}
--run-after={date_param}",
# Test trigger without logical-date (should default to now)
"dags trigger --dag-id=example_bash_operator",
"dags pause example_bash_operator",
"dags unpause example_bash_operator",
# DAG Run commands
- f'dagrun get --dag-id=example_bash_operator
--dag-run-id="manual__{ONE_DATE_PARAM}"',
+ 'dagrun get --dag-id=example_bash_operator
--dag-run-id="manual__{date_param}"',
"dags update --dag-id=example_bash_operator --no-is-paused",
# DAG Run commands
"dagrun list --dag-id example_bash_operator --state success --limit=1",
# XCom commands - need a DAG run with completed tasks
- f'xcom add --dag-id=example_bash_operator
--dag-run-id="manual__{ONE_DATE_PARAM}" --task-id=runme_0 --key=test_xcom_key
--value=\'{{"test": "value"}}\'',
- f'xcom get --dag-id=example_bash_operator
--dag-run-id="manual__{ONE_DATE_PARAM}" --task-id=runme_0 --key=test_xcom_key',
- f'xcom list --dag-id=example_bash_operator
--dag-run-id="manual__{ONE_DATE_PARAM}" --task-id=runme_0',
- f'xcom edit --dag-id=example_bash_operator
--dag-run-id="manual__{ONE_DATE_PARAM}" --task-id=runme_0 --key=test_xcom_key
--value=\'{{"updated": "value"}}\'',
- f'xcom delete --dag-id=example_bash_operator
--dag-run-id="manual__{ONE_DATE_PARAM}" --task-id=runme_0 --key=test_xcom_key',
+ 'xcom add --dag-id=example_bash_operator
--dag-run-id="manual__{date_param}" --task-id=runme_0 --key=test_xcom_key
--value=\'{{"test": "value"}}\'',
+ 'xcom get --dag-id=example_bash_operator
--dag-run-id="manual__{date_param}" --task-id=runme_0 --key=test_xcom_key',
+ 'xcom list --dag-id=example_bash_operator
--dag-run-id="manual__{date_param}" --task-id=runme_0',
+ 'xcom edit --dag-id=example_bash_operator
--dag-run-id="manual__{date_param}" --task-id=runme_0 --key=test_xcom_key
--value=\'{{"updated": "value"}}\'',
+ 'xcom delete --dag-id=example_bash_operator
--dag-run-id="manual__{date_param}" --task-id=runme_0 --key=test_xcom_key',
# Jobs commands
"jobs list",
# Pools commands
@@ -124,11 +123,38 @@ TEST_COMMANDS = [
"version --remote",
]
+DATE_PARAM_1 = date_param()
+DATE_PARAM_2 = date_param()
+TEST_COMMANDS_DEBUG_MODE = [LOGIN_COMMAND] +
[test.format(date_param=DATE_PARAM_1) for test in TEST_COMMANDS]
+TEST_COMMANDS_SKIP_KEYRING = [LOGIN_COMMAND_SKIP_KEYRING] + [
+ test.format(date_param=DATE_PARAM_2) for test in TEST_COMMANDS
+]
+
@pytest.mark.flaky(reruns=3, reruns_delay=1)
@pytest.mark.parametrize(
- "command", TEST_COMMANDS, ids=[" ".join(command.split(" ", 2)[:2]) for
command in TEST_COMMANDS]
+ "command",
+ TEST_COMMANDS_DEBUG_MODE,
+ ids=[" ".join(command.split(" ", 2)[:2]) for command in
TEST_COMMANDS_DEBUG_MODE],
)
def test_airflowctl_commands(command: str, run_command):
"""Test airflowctl commands using docker-compose environment."""
- run_command(command)
+ env_vars = {"AIRFLOW_CLI_DEBUG_MODE": "true"}
+
+ run_command(command, env_vars, skip_login=True)
+
+
[email protected](reruns=3, reruns_delay=1)
[email protected](
+ "command",
+ TEST_COMMANDS_SKIP_KEYRING,
+ ids=[" ".join(command.split(" ", 2)[:2]) for command in
TEST_COMMANDS_SKIP_KEYRING],
+)
+def test_airflowctl_commands_skip_keyring(command: str, api_token: str,
run_command):
+ """Test airflowctl commands using docker-compose environment without using
keyring."""
+ env_vars = {}
+ env_vars["AIRFLOW_CLI_TOKEN"] = api_token
+ env_vars["AIRFLOW_CLI_DEBUG_MODE"] = "false"
+ env_vars["AIRFLOW_CLI_ENVIRONMENT"] = "nokeyring"
+
+ run_command(command, env_vars, skip_login=True)
diff --git
a/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py
b/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py
index 84719bb4cc3..776629da3a2 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py
@@ -41,7 +41,9 @@ def test_config_sensitive_masking(command: str, run_command):
Airflow API, sensitive values (like fernet_key, sql_alchemy_conn) appear
masked
as '< hidden >' and do not leak actual secret values.
"""
- stdout_result = run_command(command)
+
+ env_vars = {"AIRFLOW_CLI_DEBUG_MODE": "true"}
+ stdout_result = run_command(command, env_vars)
# CRITICAL: Verify that sensitive values are masked
# The Airflow API returns masked values as "< hidden >" for sensitive
configs
diff --git a/airflow-ctl/README.md b/airflow-ctl/README.md
index 28f1b473812..19e11b1369d 100644
--- a/airflow-ctl/README.md
+++ b/airflow-ctl/README.md
@@ -33,7 +33,8 @@ A command-line tool for interacting with Apache Airflow
instances through the Ai
- Python 3.10 or later (compatible with Python >= 3.10 and < 3.13)
- Network access to an Apache Airflow instance with REST API enabled
-- Keyring backend installed in operating system for secure token storage
+- \[Recommended\] Keyring backend installed in operating system for secure
token storage.
+ - In case there's no keyring available (common in headless environments) you
can provide the token to each command. See the [Security
page](https://airflow.apache.org/docs/apache-airflow-ctl/stable/security.html)
for more information.
## Usage
diff --git a/airflow-ctl/docs/howto/index.rst b/airflow-ctl/docs/howto/index.rst
index 12946aed449..155dab5fc04 100644
--- a/airflow-ctl/docs/howto/index.rst
+++ b/airflow-ctl/docs/howto/index.rst
@@ -53,6 +53,7 @@ airflowctl needs to be able to connect to the Airflow API.
You should pass API U
You can also set the environment variable ``AIRFLOW_CLI_TOKEN`` to the token
to use for authentication.
There are two ways to authenticate with the Airflow API:
+
1. Using a token acquired from the Airflow API
.. code-block:: bash
@@ -61,17 +62,18 @@ There are two ways to authenticate with the Airflow API:
2. Using a username and password
-
.. code-block:: bash
airflowctl auth login --api-url <api_url> --username <username> --password
<password> --env <env_name:production>
-3. (optional) Using a token acquired from the Airflow API and username and
password
+If there's no keyring available, common in headless systems like docker
images, you can still use the tool by setting
+the environment variable ``AIRFLOW_CLI_TOKEN``.
.. code-block:: bash
export AIRFLOW_CLI_TOKEN=<token>
- airflowctl auth login --api-url <api_url> --env <env_name>
+ airflowctl auth login --api-url <api_url> --env <env_name:production>
--skip-keyring
+
In both cases token is securely stored in the keyring backend. Only
configuration persisted in ``~/.config/airflow`` file
is the API URL and the environment name. The token is stored in the keyring
backend and is not persisted in the
@@ -108,6 +110,10 @@ If you provide a username via ``--username`` this is the
required password to au
The name of the environment to create or update. The default value is
``production``.
This parameter is useful when you want to manage multiple Airflow environments.
+**--skip-keyring**: This parameter is optional.
+Useful when there's no keyring available in the system where airflowctl is
running.
+Set ``AIRFLOW_CLI_TOKEN`` or use the ``--api-token`` flag for next commands.
+
More Usage and Help Pictures
''''''''''''''''''''''''''''
For more information use
diff --git a/airflow-ctl/docs/images/command_hashes.txt
b/airflow-ctl/docs/images/command_hashes.txt
index 7f990f14b1b..46f6f42baf0 100644
--- a/airflow-ctl/docs/images/command_hashes.txt
+++ b/airflow-ctl/docs/images/command_hashes.txt
@@ -10,5 +10,5 @@ jobs:7f8680afff230eb9940bc7fca727bd52
pools:03fc7d948cbecf16ff8d640eb8f0ce43
providers:1c0afb2dff31d93ab2934b032a2250ab
variables:0354f8f4b0dde1c3771ed1568692c6ae
-version:d4a7a6229b3a204f114283b62eac789b
-auth login:5277c653ff6dce51f37472dc0bda9775
+version:31f4efdf8de0dbaaa4fac71ff7efecc3
+auth login:f85e04072626ab4ae17ad17e4a077bf2
diff --git a/airflow-ctl/docs/images/output_auth_login.svg
b/airflow-ctl/docs/images/output_auth_login.svg
index afa17cb9b56..006ed5bc266 100644
--- a/airflow-ctl/docs/images/output_auth_login.svg
+++ b/airflow-ctl/docs/images/output_auth_login.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 811 440.4"
xmlns="http://www.w3.org/2000/svg">
+<svg class="rich-terminal" viewBox="0 0 933 464.79999999999995"
xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@@ -19,103 +19,107 @@
font-weight: 700;
}
- .terminal-2480777825-matrix {
+ .terminal-938154658-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2480777825-title {
+ .terminal-938154658-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2480777825-r1 { fill: #ff8700 }
-.terminal-2480777825-r2 { fill: #c5c8c6 }
-.terminal-2480777825-r3 { fill: #808080 }
-.terminal-2480777825-r4 { fill: #68a0b3 }
-.terminal-2480777825-r5 { fill: #00af87 }
+ .terminal-938154658-r1 { fill: #ff8700 }
+.terminal-938154658-r2 { fill: #c5c8c6 }
+.terminal-938154658-r3 { fill: #808080 }
+.terminal-938154658-r4 { fill: #68a0b3 }
+.terminal-938154658-r5 { fill: #00af87 }
</style>
<defs>
- <clipPath id="terminal-2480777825-clip-terminal">
- <rect x="0" y="0" width="792.0" height="389.4" />
+ <clipPath id="terminal-938154658-clip-terminal">
+ <rect x="0" y="0" width="914.0" height="413.79999999999995" />
</clipPath>
- <clipPath id="terminal-2480777825-line-0">
- <rect x="0" y="1.5" width="793" height="24.65"/>
+ <clipPath id="terminal-938154658-line-0">
+ <rect x="0" y="1.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-1">
- <rect x="0" y="25.9" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-1">
+ <rect x="0" y="25.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-2">
- <rect x="0" y="50.3" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-2">
+ <rect x="0" y="50.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-3">
- <rect x="0" y="74.7" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-3">
+ <rect x="0" y="74.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-4">
- <rect x="0" y="99.1" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-4">
+ <rect x="0" y="99.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-5">
- <rect x="0" y="123.5" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-5">
+ <rect x="0" y="123.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-6">
- <rect x="0" y="147.9" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-6">
+ <rect x="0" y="147.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-7">
- <rect x="0" y="172.3" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-7">
+ <rect x="0" y="172.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-8">
- <rect x="0" y="196.7" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-8">
+ <rect x="0" y="196.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-9">
- <rect x="0" y="221.1" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-9">
+ <rect x="0" y="221.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-10">
- <rect x="0" y="245.5" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-10">
+ <rect x="0" y="245.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-11">
- <rect x="0" y="269.9" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-11">
+ <rect x="0" y="269.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-12">
- <rect x="0" y="294.3" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-12">
+ <rect x="0" y="294.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-13">
- <rect x="0" y="318.7" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-13">
+ <rect x="0" y="318.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-2480777825-line-14">
- <rect x="0" y="343.1" width="793" height="24.65"/>
+<clipPath id="terminal-938154658-line-14">
+ <rect x="0" y="343.1" width="915" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-938154658-line-15">
+ <rect x="0" y="367.5" 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="438.4" rx="8"/>
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1"
x="1" y="1" width="931" height="462.8" 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-2480777825-clip-terminal)">
+ <g transform="translate(9, 41)"
clip-path="url(#terminal-938154658-clip-terminal)">
- <g class="terminal-2480777825-matrix">
- <text class="terminal-2480777825-r1" x="0" y="20" textLength="73.2"
clip-path="url(#terminal-2480777825-line-0)">Usage:</text><text
class="terminal-2480777825-r3" x="85.4" y="20" textLength="256.2"
clip-path="url(#terminal-2480777825-line-0)">airflowctl auth login</text><text
class="terminal-2480777825-r2" x="341.6" y="20" textLength="24.4"
clip-path="url(#terminal-2480777825-line-0)"> [</text><text
class="terminal-2480777825-r4" x="366" y="20" textLength="24.4" clip-p [...]
-</text><text class="terminal-2480777825-r2" x="0" y="44.4" textLength="366"
clip-path="url(#terminal-2480777825-line-1)">                             [</text><text
class="terminal-2480777825-r4" x="366" y="44.4" textLength="109.8"
clip-path="url(#terminal-2480777825-line-1)">--api-url</text><text
class="terminal-2480777825-r5" x="488" y="44.4" t [...]
-</text><text class="terminal-2480777825-r2" x="0" y="68.8" textLength="366"
clip-path="url(#terminal-2480777825-line-2)">                             [</text><text
class="terminal-2480777825-r4" x="366" y="68.8" textLength="122"
clip-path="url(#terminal-2480777825-line-2)">--password</text><text
class="terminal-2480777825-r2" x="488" y="68.8" te [...]
-</text><text class="terminal-2480777825-r2" x="0" y="93.2" textLength="366"
clip-path="url(#terminal-2480777825-line-3)">                             [</text><text
class="terminal-2480777825-r4" x="366" y="93.2" textLength="122"
clip-path="url(#terminal-2480777825-line-3)">--username</text><text
class="terminal-2480777825-r5" x="500.2" y="93.2" [...]
-</text><text class="terminal-2480777825-r2" x="793" y="117.6"
textLength="12.2" clip-path="url(#terminal-2480777825-line-4)">
-</text><text class="terminal-2480777825-r2" x="0" y="142" textLength="366"
clip-path="url(#terminal-2480777825-line-5)">Login to the metadata database</text><text
class="terminal-2480777825-r2" x="793" y="142" textLength="12.2"
clip-path="url(#terminal-2480777825-line-5)">
-</text><text class="terminal-2480777825-r2" x="793" y="166.4"
textLength="12.2" clip-path="url(#terminal-2480777825-line-6)">
-</text><text class="terminal-2480777825-r1" x="0" y="190.8" textLength="97.6"
clip-path="url(#terminal-2480777825-line-7)">Options:</text><text
class="terminal-2480777825-r2" x="793" y="190.8" textLength="12.2"
clip-path="url(#terminal-2480777825-line-7)">
-</text><text class="terminal-2480777825-r4" x="24.4" y="215.2"
textLength="24.4" clip-path="url(#terminal-2480777825-line-8)">-h</text><text
class="terminal-2480777825-r2" x="48.8" y="215.2" textLength="24.4"
clip-path="url(#terminal-2480777825-line-8)">, </text><text
class="terminal-2480777825-r4" x="73.2" y="215.2" textLength="73.2"
clip-path="url(#terminal-2480777825-line-8)">--help</text><text
class="terminal-2480777825-r2" x="292.8" y="215.2" textLength="378.2"
clip-path="url(# [...]
-</text><text class="terminal-2480777825-r4" x="24.4" y="239.6"
textLength="134.2"
clip-path="url(#terminal-2480777825-line-9)">--api-token</text><text
class="terminal-2480777825-r5" x="170.8" y="239.6" textLength="109.8"
clip-path="url(#terminal-2480777825-line-9)">API_TOKEN</text><text
class="terminal-2480777825-r2" x="793" y="239.6" textLength="12.2"
clip-path="url(#terminal-2480777825-line-9)">
-</text><text class="terminal-2480777825-r2" x="292.8" y="264" textLength="427"
clip-path="url(#terminal-2480777825-line-10)">The token to use for authentication</text><text
class="terminal-2480777825-r2" x="793" y="264" textLength="12.2"
clip-path="url(#terminal-2480777825-line-10)">
-</text><text class="terminal-2480777825-r4" x="24.4" y="288.4"
textLength="109.8"
clip-path="url(#terminal-2480777825-line-11)">--api-url</text><text
class="terminal-2480777825-r5" x="146.4" y="288.4" textLength="85.4"
clip-path="url(#terminal-2480777825-line-11)">API_URL</text><text
class="terminal-2480777825-r2" x="292.8" y="288.4" textLength="439.2"
clip-path="url(#terminal-2480777825-line-11)">The URL of the metadata database API</text><text
class="termi [...]
-</text><text class="terminal-2480777825-r4" x="24.4" y="312.8"
textLength="24.4" clip-path="url(#terminal-2480777825-line-12)">-e</text><text
class="terminal-2480777825-r2" x="48.8" y="312.8" textLength="24.4"
clip-path="url(#terminal-2480777825-line-12)">, </text><text
class="terminal-2480777825-r4" x="73.2" y="312.8" textLength="61"
clip-path="url(#terminal-2480777825-line-12)">--env</text><text
class="terminal-2480777825-r5" x="146.4" y="312.8" textLength="36.6"
clip-path="url(#t [...]
-</text><text class="terminal-2480777825-r4" x="24.4" y="337.2"
textLength="122"
clip-path="url(#terminal-2480777825-line-13)">--password</text><text
class="terminal-2480777825-r2" x="146.4" y="337.2" textLength="24.4"
clip-path="url(#terminal-2480777825-line-13)"> [</text><text
class="terminal-2480777825-r5" x="170.8" y="337.2" textLength="97.6"
clip-path="url(#terminal-2480777825-line-13)">PASSWORD</text><text
class="terminal-2480777825-r2" x="268.4" y="337.2" textLength="12.2" cli [...]
-</text><text class="terminal-2480777825-r2" x="292.8" y="361.6"
textLength="463.6"
clip-path="url(#terminal-2480777825-line-14)">The password to use for authentication</text><text
class="terminal-2480777825-r2" x="793" y="361.6" textLength="12.2"
clip-path="url(#terminal-2480777825-line-14)">
-</text><text class="terminal-2480777825-r4" x="24.4" y="386" textLength="122"
clip-path="url(#terminal-2480777825-line-15)">--username</text><text
class="terminal-2480777825-r5" x="158.6" y="386" textLength="97.6"
clip-path="url(#terminal-2480777825-line-15)">USERNAME</text><text
class="terminal-2480777825-r2" x="292.8" y="386" textLength="463.6"
clip-path="url(#terminal-2480777825-line-15)">The username to use for authentication</text><text
class="terminal-24807 [...]
+ <g class="terminal-938154658-matrix">
+ <text class="terminal-938154658-r1" x="0" y="20" textLength="73.2"
clip-path="url(#terminal-938154658-line-0)">Usage:</text><text
class="terminal-938154658-r3" x="85.4" y="20" textLength="256.2"
clip-path="url(#terminal-938154658-line-0)">airflowctl auth login</text><text
class="terminal-938154658-r2" x="341.6" y="20" textLength="24.4"
clip-path="url(#terminal-938154658-line-0)"> [</text><text
class="terminal-938154658-r4" x="366" y="20" textLength="24.4" clip-path="ur
[...]
+</text><text class="terminal-938154658-r2" x="0" y="44.4" textLength="366"
clip-path="url(#terminal-938154658-line-1)">                             [</text><text
class="terminal-938154658-r4" x="366" y="44.4" textLength="109.8"
clip-path="url(#terminal-938154658-line-1)">--api-url</text><text
class="terminal-938154658-r5" x="488" y="44.4" textLe [...]
+</text><text class="terminal-938154658-r2" x="0" y="68.8" textLength="366"
clip-path="url(#terminal-938154658-line-2)">                             [</text><text
class="terminal-938154658-r4" x="366" y="68.8" textLength="122"
clip-path="url(#terminal-938154658-line-2)">--password</text><text
class="terminal-938154658-r2" x="488" y="68.8" textLen [...]
+</text><text class="terminal-938154658-r2" x="0" y="93.2" textLength="366"
clip-path="url(#terminal-938154658-line-3)">                             [</text><text
class="terminal-938154658-r4" x="366" y="93.2" textLength="122"
clip-path="url(#terminal-938154658-line-3)">--username</text><text
class="terminal-938154658-r5" x="500.2" y="93.2" textL [...]
+</text><text class="terminal-938154658-r2" x="915" y="117.6" textLength="12.2"
clip-path="url(#terminal-938154658-line-4)">
+</text><text class="terminal-938154658-r2" x="0" y="142" textLength="366"
clip-path="url(#terminal-938154658-line-5)">Login to the metadata database</text><text
class="terminal-938154658-r2" x="915" y="142" textLength="12.2"
clip-path="url(#terminal-938154658-line-5)">
+</text><text class="terminal-938154658-r2" x="915" y="166.4" textLength="12.2"
clip-path="url(#terminal-938154658-line-6)">
+</text><text class="terminal-938154658-r1" x="0" y="190.8" textLength="97.6"
clip-path="url(#terminal-938154658-line-7)">Options:</text><text
class="terminal-938154658-r2" x="915" y="190.8" textLength="12.2"
clip-path="url(#terminal-938154658-line-7)">
+</text><text class="terminal-938154658-r4" x="24.4" y="215.2"
textLength="24.4" clip-path="url(#terminal-938154658-line-8)">-h</text><text
class="terminal-938154658-r2" x="48.8" y="215.2" textLength="24.4"
clip-path="url(#terminal-938154658-line-8)">, </text><text
class="terminal-938154658-r4" x="73.2" y="215.2" textLength="73.2"
clip-path="url(#terminal-938154658-line-8)">--help</text><text
class="terminal-938154658-r2" x="292.8" y="215.2" textLength="378.2"
clip-path="url(#termina [...]
+</text><text class="terminal-938154658-r4" x="24.4" y="239.6"
textLength="134.2"
clip-path="url(#terminal-938154658-line-9)">--api-token</text><text
class="terminal-938154658-r5" x="170.8" y="239.6" textLength="109.8"
clip-path="url(#terminal-938154658-line-9)">API_TOKEN</text><text
class="terminal-938154658-r2" x="915" y="239.6" textLength="12.2"
clip-path="url(#terminal-938154658-line-9)">
+</text><text class="terminal-938154658-r2" x="292.8" y="264" textLength="427"
clip-path="url(#terminal-938154658-line-10)">The token to use for authentication</text><text
class="terminal-938154658-r2" x="915" y="264" textLength="12.2"
clip-path="url(#terminal-938154658-line-10)">
+</text><text class="terminal-938154658-r4" x="24.4" y="288.4"
textLength="109.8"
clip-path="url(#terminal-938154658-line-11)">--api-url</text><text
class="terminal-938154658-r5" x="146.4" y="288.4" textLength="85.4"
clip-path="url(#terminal-938154658-line-11)">API_URL</text><text
class="terminal-938154658-r2" x="292.8" y="288.4" textLength="439.2"
clip-path="url(#terminal-938154658-line-11)">The URL of the metadata database API</text><text
class="terminal-93 [...]
+</text><text class="terminal-938154658-r4" x="24.4" y="312.8"
textLength="24.4" clip-path="url(#terminal-938154658-line-12)">-e</text><text
class="terminal-938154658-r2" x="48.8" y="312.8" textLength="24.4"
clip-path="url(#terminal-938154658-line-12)">, </text><text
class="terminal-938154658-r4" x="73.2" y="312.8" textLength="61"
clip-path="url(#terminal-938154658-line-12)">--env</text><text
class="terminal-938154658-r5" x="146.4" y="312.8" textLength="36.6"
clip-path="url(#terminal [...]
+</text><text class="terminal-938154658-r4" x="24.4" y="337.2" textLength="122"
clip-path="url(#terminal-938154658-line-13)">--password</text><text
class="terminal-938154658-r2" x="146.4" y="337.2" textLength="24.4"
clip-path="url(#terminal-938154658-line-13)"> [</text><text
class="terminal-938154658-r5" x="170.8" y="337.2" textLength="97.6"
clip-path="url(#terminal-938154658-line-13)">PASSWORD</text><text
class="terminal-938154658-r2" x="268.4" y="337.2" textLength="12.2" clip-path=
[...]
+</text><text class="terminal-938154658-r2" x="292.8" y="361.6"
textLength="463.6"
clip-path="url(#terminal-938154658-line-14)">The password to use for authentication</text><text
class="terminal-938154658-r2" x="915" y="361.6" textLength="12.2"
clip-path="url(#terminal-938154658-line-14)">
+</text><text class="terminal-938154658-r4" x="24.4" y="386" textLength="170.8"
clip-path="url(#terminal-938154658-line-15)">--skip-keyring</text><text
class="terminal-938154658-r2" x="292.8" y="386" textLength="427"
clip-path="url(#terminal-938154658-line-15)">Skip storing credentials in keyring</text><text
class="terminal-938154658-r2" x="915" y="386" textLength="12.2"
clip-path="url(#terminal-938154658-line-15)">
+</text><text class="terminal-938154658-r4" x="24.4" y="410.4" textLength="122"
clip-path="url(#terminal-938154658-line-16)">--username</text><text
class="terminal-938154658-r5" x="158.6" y="410.4" textLength="97.6"
clip-path="url(#terminal-938154658-line-16)">USERNAME</text><text
class="terminal-938154658-r2" x="292.8" y="410.4" textLength="463.6"
clip-path="url(#terminal-938154658-line-16)">The username to use for authentication</text><text
class="terminal-93815 [...]
</text>
</g>
</g>
diff --git a/airflow-ctl/docs/images/output_version.svg
b/airflow-ctl/docs/images/output_version.svg
index a7ce436c8b8..ade6b57810e 100644
--- a/airflow-ctl/docs/images/output_version.svg
+++ b/airflow-ctl/docs/images/output_version.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 811 269.6"
xmlns="http://www.w3.org/2000/svg">
+<svg class="rich-terminal" viewBox="0 0 933 342.79999999999995"
xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@@ -19,75 +19,87 @@
font-weight: 700;
}
- .terminal-3399644434-matrix {
+ .terminal-1180839071-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3399644434-title {
+ .terminal-1180839071-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3399644434-r1 { fill: #ff8700 }
-.terminal-3399644434-r2 { fill: #c5c8c6 }
-.terminal-3399644434-r3 { fill: #808080 }
-.terminal-3399644434-r4 { fill: #68a0b3 }
-.terminal-3399644434-r5 { fill: #00af87 }
+ .terminal-1180839071-r1 { fill: #ff8700 }
+.terminal-1180839071-r2 { fill: #c5c8c6 }
+.terminal-1180839071-r3 { fill: #808080 }
+.terminal-1180839071-r4 { fill: #68a0b3 }
+.terminal-1180839071-r5 { fill: #00af87 }
</style>
<defs>
- <clipPath id="terminal-3399644434-clip-terminal">
- <rect x="0" y="0" width="792.0" height="218.6" />
+ <clipPath id="terminal-1180839071-clip-terminal">
+ <rect x="0" y="0" width="914.0" height="291.79999999999995" />
</clipPath>
- <clipPath id="terminal-3399644434-line-0">
- <rect x="0" y="1.5" width="793" height="24.65"/>
+ <clipPath id="terminal-1180839071-line-0">
+ <rect x="0" y="1.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3399644434-line-1">
- <rect x="0" y="25.9" width="793" height="24.65"/>
+<clipPath id="terminal-1180839071-line-1">
+ <rect x="0" y="25.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3399644434-line-2">
- <rect x="0" y="50.3" width="793" height="24.65"/>
+<clipPath id="terminal-1180839071-line-2">
+ <rect x="0" y="50.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3399644434-line-3">
- <rect x="0" y="74.7" width="793" height="24.65"/>
+<clipPath id="terminal-1180839071-line-3">
+ <rect x="0" y="74.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3399644434-line-4">
- <rect x="0" y="99.1" width="793" height="24.65"/>
+<clipPath id="terminal-1180839071-line-4">
+ <rect x="0" y="99.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3399644434-line-5">
- <rect x="0" y="123.5" width="793" height="24.65"/>
+<clipPath id="terminal-1180839071-line-5">
+ <rect x="0" y="123.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3399644434-line-6">
- <rect x="0" y="147.9" width="793" height="24.65"/>
+<clipPath id="terminal-1180839071-line-6">
+ <rect x="0" y="147.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3399644434-line-7">
- <rect x="0" y="172.3" width="793" height="24.65"/>
+<clipPath id="terminal-1180839071-line-7">
+ <rect x="0" y="172.3" width="915" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1180839071-line-8">
+ <rect x="0" y="196.7" width="915" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1180839071-line-9">
+ <rect x="0" y="221.1" width="915" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1180839071-line-10">
+ <rect x="0" y="245.5" 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="267.6" rx="8"/>
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1"
x="1" y="1" width="931" height="340.8" 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-3399644434-clip-terminal)">
+ <g transform="translate(9, 41)"
clip-path="url(#terminal-1180839071-clip-terminal)">
- <g class="terminal-3399644434-matrix">
- <text class="terminal-3399644434-r1" x="0" y="20" textLength="73.2"
clip-path="url(#terminal-3399644434-line-0)">Usage:</text><text
class="terminal-3399644434-r3" x="85.4" y="20" textLength="219.6"
clip-path="url(#terminal-3399644434-line-0)">airflowctl version</text><text
class="terminal-3399644434-r2" x="305" y="20" textLength="24.4"
clip-path="url(#terminal-3399644434-line-0)"> [</text><text
class="terminal-3399644434-r4" x="329.4" y="20" textLength="24.4"
clip-path="url [...]
-</text><text class="terminal-3399644434-r2" x="793" y="44.4" textLength="12.2"
clip-path="url(#terminal-3399644434-line-1)">
-</text><text class="terminal-3399644434-r2" x="0" y="68.8" textLength="292.8"
clip-path="url(#terminal-3399644434-line-2)">Show version information</text><text
class="terminal-3399644434-r2" x="793" y="68.8" textLength="12.2"
clip-path="url(#terminal-3399644434-line-2)">
-</text><text class="terminal-3399644434-r2" x="793" y="93.2" textLength="12.2"
clip-path="url(#terminal-3399644434-line-3)">
-</text><text class="terminal-3399644434-r1" x="0" y="117.6" textLength="97.6"
clip-path="url(#terminal-3399644434-line-4)">Options:</text><text
class="terminal-3399644434-r2" x="793" y="117.6" textLength="12.2"
clip-path="url(#terminal-3399644434-line-4)">
-</text><text class="terminal-3399644434-r4" x="24.4" y="142" textLength="24.4"
clip-path="url(#terminal-3399644434-line-5)">-h</text><text
class="terminal-3399644434-r2" x="48.8" y="142" textLength="24.4"
clip-path="url(#terminal-3399644434-line-5)">, </text><text
class="terminal-3399644434-r4" x="73.2" y="142" textLength="73.2"
clip-path="url(#terminal-3399644434-line-5)">--help</text><text
class="terminal-3399644434-r2" x="256.2" y="142" textLength="378.2"
clip-path="url(#terminal [...]
-</text><text class="terminal-3399644434-r4" x="24.4" y="166.4"
textLength="24.4" clip-path="url(#terminal-3399644434-line-6)">-e</text><text
class="terminal-3399644434-r2" x="48.8" y="166.4" textLength="24.4"
clip-path="url(#terminal-3399644434-line-6)">, </text><text
class="terminal-3399644434-r4" x="73.2" y="166.4" textLength="61"
clip-path="url(#terminal-3399644434-line-6)">--env</text><text
class="terminal-3399644434-r5" x="146.4" y="166.4" textLength="36.6"
clip-path="url(#term [...]
-</text><text class="terminal-3399644434-r4" x="24.4" y="190.8"
textLength="97.6"
clip-path="url(#terminal-3399644434-line-7)">--remote</text><text
class="terminal-3399644434-r2" x="256.2" y="190.8" textLength="536.8"
clip-path="url(#terminal-3399644434-line-7)">Fetch the Airflow version in remote server, </text><text
class="terminal-3399644434-r2" x="793" y="190.8" textLength="12.2"
clip-path="url(#terminal-3399644434-line-7)">
-</text><text class="terminal-3399644434-r2" x="0" y="215.2" textLength="597.8"
clip-path="url(#terminal-3399644434-line-8)">otherwise only shows the local airflowctl version</text><text
class="terminal-3399644434-r2" x="793" y="215.2" textLength="12.2"
clip-path="url(#terminal-3399644434-line-8)">
+ <g class="terminal-1180839071-matrix">
+ <text class="terminal-1180839071-r1" x="0" y="20" textLength="73.2"
clip-path="url(#terminal-1180839071-line-0)">Usage:</text><text
class="terminal-1180839071-r3" x="85.4" y="20" textLength="219.6"
clip-path="url(#terminal-1180839071-line-0)">airflowctl version</text><text
class="terminal-1180839071-r2" x="305" y="20" textLength="24.4"
clip-path="url(#terminal-1180839071-line-0)"> [</text><text
class="terminal-1180839071-r4" x="329.4" y="20" textLength="24.4"
clip-path="url [...]
+</text><text class="terminal-1180839071-r2" x="0" y="44.4" textLength="329.4"
clip-path="url(#terminal-1180839071-line-1)">                          [</text><text
class="terminal-1180839071-r4" x="329.4" y="44.4" textLength="97.6"
clip-path="url(#terminal-1180839071-line-1)">--remote</text><text
class="terminal-1180839071-r2" x="427" y="44.4" textLength="12.2" [...]
+</text><text class="terminal-1180839071-r2" x="915" y="68.8" textLength="12.2"
clip-path="url(#terminal-1180839071-line-2)">
+</text><text class="terminal-1180839071-r2" x="0" y="93.2" textLength="292.8"
clip-path="url(#terminal-1180839071-line-3)">Show version information</text><text
class="terminal-1180839071-r2" x="915" y="93.2" textLength="12.2"
clip-path="url(#terminal-1180839071-line-3)">
+</text><text class="terminal-1180839071-r2" x="915" y="117.6"
textLength="12.2" clip-path="url(#terminal-1180839071-line-4)">
+</text><text class="terminal-1180839071-r1" x="0" y="142" textLength="97.6"
clip-path="url(#terminal-1180839071-line-5)">Options:</text><text
class="terminal-1180839071-r2" x="915" y="142" textLength="12.2"
clip-path="url(#terminal-1180839071-line-5)">
+</text><text class="terminal-1180839071-r4" x="24.4" y="166.4"
textLength="24.4" clip-path="url(#terminal-1180839071-line-6)">-h</text><text
class="terminal-1180839071-r2" x="48.8" y="166.4" textLength="24.4"
clip-path="url(#terminal-1180839071-line-6)">, </text><text
class="terminal-1180839071-r4" x="73.2" y="166.4" textLength="73.2"
clip-path="url(#terminal-1180839071-line-6)">--help</text><text
class="terminal-1180839071-r2" x="292.8" y="166.4" textLength="378.2"
clip-path="url(# [...]
+</text><text class="terminal-1180839071-r4" x="24.4" y="190.8"
textLength="134.2"
clip-path="url(#terminal-1180839071-line-7)">--api-token</text><text
class="terminal-1180839071-r5" x="170.8" y="190.8" textLength="109.8"
clip-path="url(#terminal-1180839071-line-7)">API_TOKEN</text><text
class="terminal-1180839071-r2" x="915" y="190.8" textLength="12.2"
clip-path="url(#terminal-1180839071-line-7)">
+</text><text class="terminal-1180839071-r2" x="292.8" y="215.2"
textLength="427"
clip-path="url(#terminal-1180839071-line-8)">The token to use for authentication</text><text
class="terminal-1180839071-r2" x="915" y="215.2" textLength="12.2"
clip-path="url(#terminal-1180839071-line-8)">
+</text><text class="terminal-1180839071-r4" x="24.4" y="239.6"
textLength="24.4" clip-path="url(#terminal-1180839071-line-9)">-e</text><text
class="terminal-1180839071-r2" x="48.8" y="239.6" textLength="24.4"
clip-path="url(#terminal-1180839071-line-9)">, </text><text
class="terminal-1180839071-r4" x="73.2" y="239.6" textLength="61"
clip-path="url(#terminal-1180839071-line-9)">--env</text><text
class="terminal-1180839071-r5" x="146.4" y="239.6" textLength="36.6"
clip-path="url(#term [...]
+</text><text class="terminal-1180839071-r4" x="24.4" y="264" textLength="97.6"
clip-path="url(#terminal-1180839071-line-10)">--remote</text><text
class="terminal-1180839071-r2" x="292.8" y="264" textLength="536.8"
clip-path="url(#terminal-1180839071-line-10)">Fetch the Airflow version in remote server, </text><text
class="terminal-1180839071-r2" x="915" y="264" textLength="12.2"
clip-path="url(#terminal-1180839071-line-10)">
+</text><text class="terminal-1180839071-r2" x="0" y="288.4" textLength="597.8"
clip-path="url(#terminal-1180839071-line-11)">otherwise only shows the local airflowctl version</text><text
class="terminal-1180839071-r2" x="915" y="288.4" textLength="12.2"
clip-path="url(#terminal-1180839071-line-11)">
</text>
</g>
</g>
diff --git a/airflow-ctl/docs/installation/prerequisites.rst
b/airflow-ctl/docs/installation/prerequisites.rst
index a155a8debd8..b01ff4c3cc4 100644
--- a/airflow-ctl/docs/installation/prerequisites.rst
+++ b/airflow-ctl/docs/installation/prerequisites.rst
@@ -25,8 +25,8 @@ The minimum memory required we recommend airflowctl to run
with is 200MB, but th
wildly on the deployment options you have.
The Keyring backend needs to be installed separately into your operating
system. This will enhance security. See :doc:`/security` for more information.
-Keyring Backend
-'''''''''''''''
+Keyring Backend \[Recommended\]
+'''''''''''''''''''''''''''''''
airflowctl uses keyring to store the API token securely. This ensures that the
token is not stored in plain text and is only accessible to authorized users.
Recommended keyring backends are:
@@ -36,6 +36,8 @@ Recommended keyring backends are:
* `KDE4 & KDE5 KWallet <https://en.wikipedia.org/wiki/KWallet>`_ (requires
`dbus <https://pypi.python.org/pypi/dbus-python>`_)
* `Windows Credential Locker
<https://docs.microsoft.com/en-us/windows/uwp/security/credential-locker>`_
+In case there's no keyring available (common in headless environments) you can
provide the token to each command. See :doc:`/security` for more information.
+
Third-Party Backends
====================
diff --git a/airflow-ctl/docs/security.rst b/airflow-ctl/docs/security.rst
index 8ec5ea6d6f4..fb08f4f68b3 100644
--- a/airflow-ctl/docs/security.rst
+++ b/airflow-ctl/docs/security.rst
@@ -23,6 +23,7 @@ airflowctl facilitates the seamless deployment of CLI and API
features together,
- **Authentication**: airflowctl uses authentication to ensure that only
authorized users can access the system. This is done using an API Token. See
more on https://airflow.apache.org/docs/apache-airflow/stable/security/api.html
- **Keyring**: airflowctl uses keyring to store the API Token securely. This
ensures that the Token is not stored in plain text and is only accessible to
authorized users.
+ - In case no keyring is available, you can set the ``AIRFLOW_CLI_TOKEN``
environment variable or the ``--api-token`` flag for each command. Be cautious
of not exposing this token to others.
airflowctl API Token has its own expiration time. The default is 1 hour. You
can change it in the Airflow configuration file (airflow.cfg) by setting the
``jwt_cli_expiration_time`` parameter under the ``[api_auth]`` section. The
value is in seconds. This will impact all users using ``airflowctl``.
diff --git a/airflow-ctl/src/airflowctl/api/client.py
b/airflow-ctl/src/airflowctl/api/client.py
index ec703ebf69c..7c03dae3055 100644
--- a/airflow-ctl/src/airflowctl/api/client.py
+++ b/airflow-ctl/src/airflowctl/api/client.py
@@ -53,7 +53,6 @@ from airflowctl.api.operations import (
)
from airflowctl.exceptions import (
AirflowCtlCredentialNotFoundException,
- AirflowCtlException,
AirflowCtlKeyringException,
AirflowCtlNotFoundException,
)
@@ -161,8 +160,13 @@ class Credentials:
"""Generate path for the CLI config file."""
return f"{self.api_environment}.json"
- def save(self):
- """Save the credentials to keyring and URL to disk as a file."""
+ def save(self, skip_keyring: bool = False):
+ """
+ Save the credentials to keyring and URL to disk as a file.
+
+ Skip saving the token to keyring if skip_keyring is True, in this case,
+ only the config file with the API URL is created.
+ """
default_config_dir = os.environ.get("AIRFLOW_HOME",
os.path.expanduser("~/airflow"))
os.makedirs(default_config_dir, exist_ok=True)
with open(os.path.join(default_config_dir,
self.input_cli_config_file), "w") as f:
@@ -175,6 +179,8 @@ class Credentials:
) as f:
json.dump({f"api_token_{self.api_environment}":
self.api_token}, f)
else:
+ if skip_keyring:
+ return
# Replace the upstream EncryptedKeyring's unbounded password
# prompt with a bounded one before set_password can trigger it.
# The active backend may be a ChainerBackend that delegates to
@@ -184,11 +190,15 @@ class Credentials:
for candidate in candidates:
if hasattr(candidate, "_get_new_password"):
candidate._get_new_password = _bounded_get_new_password
- keyring.set_password("airflowctl",
f"api_token_{self.api_environment}", self.api_token)
- except NoKeyringError as e:
+ keyring.set_password("airflowctl",
f"api_token_{self.api_environment}", self.api_token) # type: ignore[arg-type]
+ except (NoKeyringError, NotImplementedError) as e:
log.error(e)
raise AirflowCtlKeyringException(
- "Keyring backend is not available. Cannot save credentials."
+ "Keyring backend is not available. Cannot save credentials.\n"
+ "The api url config was saved and you can still use airflowctl
"
+ "by setting the AIRFLOW_CLI_TOKEN environment variable or
passing "
+ "the --api-token flag to any command.\n"
+ "Use `airflowctl auth login --skip-keyring ...` to dismiss
this error."
) from e
except TypeError as e:
# This happens when the token is None, which is not allowed by
keyring
@@ -203,6 +213,8 @@ class Credentials:
with open(config_path) as f:
credentials = json.load(f)
self.api_url = credentials["api_url"]
+ if self.api_token is not None:
+ return self
if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
debug_creds_path = os.path.join(
default_config_dir,
f"debug_creds_{self.input_cli_config_file}"
@@ -232,13 +244,9 @@ class Credentials:
raise AirflowCtlKeyringException("Keyring backend
is not available") from e
self.api_token = None
except FileNotFoundError:
- if self.client_kind == ClientKind.AUTH:
- # Saving the URL set from the Auth Commands if Kind is AUTH
- self.save()
- elif self.client_kind == ClientKind.CLI:
+ # This is expected during the auth login command
+ if self.client_kind != ClientKind.AUTH:
raise AirflowCtlCredentialNotFoundException("No credentials
file found. Please login first.")
- else:
- raise AirflowCtlException(f"Unknown client kind:
{self.client_kind}")
return self
@@ -371,20 +379,21 @@ class Client(httpx.Client):
# API Client Decorator for CLI Actions
@contextlib.contextmanager
-def get_client(kind: Literal[ClientKind.CLI, ClientKind.AUTH] =
ClientKind.CLI):
+def get_client(kind: Literal[ClientKind.CLI, ClientKind.AUTH] =
ClientKind.CLI, api_token: str | None = None):
"""
Get CLI API client.
Don't call this method, please use @provide_api_client decorator instead.
"""
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
- credentials = Credentials(client_kind=kind).load()
+ credentials = Credentials(client_kind=kind, api_token=api_token).load()
api_client = Client(
base_url=credentials.api_url or "http://localhost:8080",
limits=httpx.Limits(max_keepalive_connections=1,
max_connections=1),
- token=credentials.api_token or str(os.getenv("AIRFLOW_CLI_TOKEN",
"")),
+ token=str(api_token or credentials.api_token),
kind=kind,
)
yield api_client
@@ -412,7 +421,8 @@ def provide_api_client(
@wraps(func)
def wrapper(*args, **kwargs) -> RT:
if "api_client" not in kwargs:
- with get_client(kind=kind) as api_client:
+ api_token = getattr(args[0], "api_token", None) if args else
None
+ with get_client(kind=kind, api_token=api_token) 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
old mode 100644
new mode 100755
index 5f7114480c2..fcc0762b9f6
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -243,6 +243,13 @@ ARG_AUTH_USERNAME = Arg(
dest="username",
help="The username to use for authentication",
)
+ARG_AUTH_SKIP_KEYRING = Arg(
+ flags=("--skip-keyring",),
+ dest="skip_keyring",
+ default=False,
+ action="store_true",
+ help="Skip storing credentials in keyring",
+)
ARG_AUTH_PASSWORD = Arg(
flags=("--password",),
type=str,
@@ -749,6 +756,23 @@ class CommandFactory:
return self.group_commands_list
+def add_auth_token_to_all_commands(commands: Iterable[CLICommand]) ->
list[CLICommand]:
+ """Add ARG_AUTH_TOKEN to all ActionCommands."""
+ new_commands: list[CLICommand] = []
+ for command in commands:
+ if isinstance(command, ActionCommand):
+ new_args = list(command.args)
+ if ARG_AUTH_TOKEN not in new_args:
+ new_args.append(ARG_AUTH_TOKEN)
+ new_commands.append(command._replace(args=new_args))
+ elif isinstance(command, GroupCommand):
+ new_subcommands =
add_auth_token_to_all_commands(command.subcommands)
+ new_commands.append(command._replace(subcommands=new_subcommands))
+ else:
+ new_commands.append(command)
+ return new_commands
+
+
def merge_commands(
base_commands: list[CLICommand], commands_will_be_merged: list[CLICommand]
) -> list[CLICommand]:
@@ -812,7 +836,13 @@ AUTH_COMMANDS = (
help="Login to the metadata database for personal usage. JWT Token
must be provided via parameter.",
description="Login to the metadata database",
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),
+ args=(
+ ARG_AUTH_URL,
+ ARG_AUTH_ENVIRONMENT,
+ ARG_AUTH_USERNAME,
+ ARG_AUTH_PASSWORD,
+ ARG_AUTH_SKIP_KEYRING,
+ ),
),
ActionCommand(
name="list-envs",
@@ -943,3 +973,5 @@ core_commands: list[CLICommand] = [
core_commands = merge_commands(
base_commands=command_factory.group_commands,
commands_will_be_merged=core_commands
)
+# Add ARG_AUTH_TOKEN to all commands
+core_commands = add_auth_token_to_all_commands(core_commands)
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
index 8cc55ff69e8..722219f52ba 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
@@ -38,7 +38,9 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
success_message = "[green]Login successful! Welcome to airflowctl![/green]"
# Check is username and password are passed
if args.username and args.password:
- # Generate empty credentials with the api_url and env
+ if args.skip_keyring:
+ 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_token="",
@@ -47,7 +49,6 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
)
# After logging in, the token will be saved in the credentials file
try:
- credentials.save()
api_client.refresh_base_url(base_url=args.api_url,
kind=ClientKind.AUTH)
login_response = api_client.login.login_with_username_and_password(
LoginBody(
@@ -78,7 +79,7 @@ def login(args, api_client=NEW_API_CLIENT) -> None:
api_url=args.api_url,
api_token=token,
api_environment=args.env,
- ).save()
+ ).save(args.skip_keyring)
rich.print(success_message)
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py
b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index 8d4a24c28b7..f79322d16fb 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -126,6 +126,38 @@ class TestCredentials:
"api_url": credentials.api_url,
}
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT":
"TEST_SAVE_NO_KEYRING"})
+ @patch("airflowctl.api.client.keyring")
+ def test_save_no_keyring(self, mock_keyring):
+ from keyring.errors import NoKeyringError
+
+ cli_client = ClientKind.CLI
+ mock_keyring.set_password.side_effect = NoKeyringError("no backend")
+
+ with pytest.raises(AirflowCtlKeyringException, match="Keyring backend
is not available"):
+ Credentials(client_kind=cli_client).save()
+
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT":
"TEST_SAVE_SKIP_KEYRING"})
+ @patch("airflowctl.api.client.keyring")
+ def test_save_no_keyring_backend_skip_keyring(self, mock_keyring):
+
+ env = "TEST_SAVE_SKIP_KEYRING"
+ cli_client = ClientKind.CLI
+ mock_keyring.set_password = MagicMock()
+ mock_keyring.get_password = MagicMock()
+
+ Credentials(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)
+ with open(os.path.join(config_dir, f"{env}.json")) as f:
+ credentials = Credentials(client_kind=cli_client,
api_token="TEST_TOKEN").load()
+ assert json.load(f) == {
+ "api_url": credentials.api_url,
+ }
+ mock_keyring.set_password.assert_not_called()
+ mock_keyring.get_password.assert_not_called()
+
@patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_LOAD"})
@patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
@patch("airflowctl.api.client.keyring")
@@ -196,6 +228,21 @@ class TestCredentials:
with pytest.raises(AirflowCtlKeyringException, match="Keyring backend
is not available"):
Credentials(client_kind=cli_client).load()
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT":
"TEST_NO_KEYRING_BACKEND"})
+ @patch("airflowctl.api.client.keyring")
+ def test_load_no_keyring_backend_token_provided(self, mock_keyring):
+ from keyring.errors import NoKeyringError
+
+ cli_client = ClientKind.CLI
+ config_dir = os.environ.get("AIRFLOW_HOME",
os.path.expanduser("~/airflow"))
+ os.makedirs(config_dir, exist_ok=True)
+ with open(os.path.join(config_dir, "TEST_NO_KEYRING_BACKEND.json"),
"w") as f:
+ json.dump({"api_url": "http://localhost:8080"}, f)
+ mock_keyring.get_password.side_effect = NoKeyringError("no backend")
+
+ credentials = Credentials(client_kind=cli_client,
api_token="TEST_TOKEN").load()
+ assert credentials.api_token == "TEST_TOKEN"
+
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 54fb83901ee..70d19d26657 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
@@ -70,6 +70,52 @@ class TestCliAuthCommands:
"airflowctl", "api_token_TEST_AUTH_LOGIN", "TEST_TOKEN"
)
+ @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
+ @patch("airflowctl.api.client.keyring")
+ def test_login_with_skip_keyring(self, mock_keyring, api_client_maker):
+ from keyring.errors import NoKeyringError
+
+ api_client = api_client_maker(
+ path="/auth/token/cli",
+ response_json=self.login_response.model_dump(),
+ expected_http_status_code=201,
+ kind=ClientKind.AUTH,
+ )
+
+ mock_keyring.set_password.side_effect = NoKeyringError("no backend")
+ with (
+ patch("sys.stdin", io.StringIO("test_password")),
+ patch("airflowctl.ctl.cli_config.getpass.getpass",
return_value="test_password"),
+ ):
+ auth_command.login(
+ self.parser.parse_args(
+ ["auth", "login", "--skip-keyring", "--api-url",
"http://localhost:8080"]
+ ),
+ api_client=api_client,
+ )
+
+ @patch("airflowctl.api.client.keyring")
+ def test_login_without_skip_keyring_raises_on_no_keyring(self,
mock_keyring, api_client_maker):
+ from keyring.errors import NoKeyringError
+
+ api_client = api_client_maker(
+ path="/auth/token/cli",
+ response_json=self.login_response.model_dump(),
+ expected_http_status_code=201,
+ kind=ClientKind.AUTH,
+ )
+
+ mock_keyring.set_password.side_effect = NoKeyringError("no backend")
+ with (
+ patch("sys.stdin", io.StringIO("test_password")),
+ patch("airflowctl.ctl.cli_config.getpass.getpass",
return_value="test_password"),
+ pytest.raises(SystemExit, match="1"),
+ ):
+ auth_command.login(
+ self.parser.parse_args(["auth", "login", "--api-url",
"http://localhost:8080"]),
+ api_client=api_client,
+ )
+
# Test auth login with username and password
@patch("airflowctl.api.client.keyring")
def test_login_with_username_and_password(self, mock_keyring,
api_client_maker):
@@ -102,7 +148,6 @@ class TestCliAuthCommands:
)
mock_keyring.set_password.assert_has_calls(
[
- mock.call("airflowctl", "api_token_production", ""),
mock.call("airflowctl", "api_token_production",
"TEST_TOKEN"),
]
)
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
index 44cc341ee1c..5ebfdc869c8 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
@@ -48,21 +48,21 @@ class TestPoolImportCommand:
"""Test import with missing file."""
non_existent = tmp_path / "non_existent.json"
with pytest.raises(SystemExit, match=f"Missing pools file
{non_existent}"):
- pool_command.import_(args=mock.MagicMock(file=non_existent))
+ pool_command.import_(mock.MagicMock(file=non_existent))
def test_import_invalid_json(self, mock_client, tmp_path):
"""Test import with invalid JSON file."""
invalid_json = tmp_path / "invalid.json"
invalid_json.write_text("invalid json")
with pytest.raises(SystemExit, match="Invalid json file"):
- pool_command.import_(args=mock.MagicMock(file=invalid_json))
+ pool_command.import_(mock.MagicMock(file=invalid_json))
def test_import_invalid_pool_config(self, mock_client, tmp_path):
"""Test import with invalid pool configuration."""
invalid_pool = tmp_path / "invalid_pool.json"
invalid_pool.write_text(json.dumps([{"invalid": "config"}]))
with pytest.raises(SystemExit, match="Invalid pool configuration:
{'invalid': 'config'}"):
- pool_command.import_(args=mock.MagicMock(file=invalid_pool))
+ pool_command.import_(mock.MagicMock(file=invalid_pool))
def test_import_success(self, mock_client, tmp_path, capsys):
"""Test successful pool import."""
@@ -87,7 +87,7 @@ class TestPoolImportCommand:
mock_client.pools.bulk.return_value = mock_bulk_builder
- pool_command.import_(args=mock.MagicMock(file=pools_file))
+ pool_command.import_(mock.MagicMock(file=pools_file))
# Verify bulk operation was called with correct parameters
mock_client.pools.bulk.assert_called_once()
@@ -134,7 +134,7 @@ class TestPoolExportCommand:
mock_pools.total_entries = 1
mock_client.pools.list.return_value = mock_pools
- pool_command.export(args=mock.MagicMock(file=export_file,
output="json"))
+ pool_command.export(mock.MagicMock(file=export_file, output="json"))
# Verify the exported file content
exported_data = json.loads(export_file.read_text())
@@ -170,7 +170,7 @@ class TestPoolExportCommand:
mock_pools.total_entries = 1
mock_client.pools.list.return_value = mock_pools
- pool_command.export(args=mock.MagicMock(file=tmp_path / "unused.json",
output="table"))
+ pool_command.export(mock.MagicMock(file=tmp_path / "unused.json",
output="table"))
# Verify console output contains the raw dict
captured = capsys.readouterr()
@@ -185,4 +185,4 @@ class TestPoolExportCommand:
mock_client.pools.list.side_effect = Exception("API Error")
with pytest.raises(SystemExit, match="Failed to export pools: API
Error"):
- pool_command.export(args=mock.MagicMock(file=export_file,
output="json"))
+ pool_command.export(mock.MagicMock(file=export_file,
output="json"))
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 3173aa4d353..6d5bf39e7ab 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -22,7 +22,15 @@ from textwrap import dedent
import pytest
-from airflowctl.ctl.cli_config import ActionCommand, CommandFactory,
GroupCommand, merge_commands
+from airflowctl.ctl.cli_config import (
+ ARG_AUTH_TOKEN,
+ ActionCommand,
+ Arg,
+ CommandFactory,
+ GroupCommand,
+ add_auth_token_to_all_commands,
+ merge_commands,
+)
@pytest.fixture
@@ -356,6 +364,68 @@ class TestCliConfigMethods:
assert "subcommand3" in sub_command_names
assert "subcommand4" in sub_command_names
+ def test_add_auth_token_to_all_commands(self, no_op_method):
+ """Test the add_auth_token_to_all_commands method."""
+ ARG_1 = Arg(
+ flags=("--arg1",),
+ )
+ ARG_2 = Arg(
+ flags=("--arg1",),
+ )
+ action_commands_1 = (
+ ActionCommand(
+ name="subcommand1",
+ help="This is subcommand 1",
+ func=no_op_method,
+ args=(),
+ ),
+ ActionCommand(
+ name="subcommand2",
+ help="This is subcommand 2",
+ func=no_op_method,
+ args=(ARG_1,),
+ ),
+ )
+ command_list = [
+ GroupCommand(
+ name="command1",
+ help="This is command 1 new help",
+ description="This is command 1 new description",
+ subcommands=action_commands_1,
+ ),
+ ActionCommand(
+ name="command2",
+ help="This is command 2",
+ func=no_op_method,
+ args=(ARG_1, ARG_2),
+ ),
+ ]
+
+ command_list = add_auth_token_to_all_commands(command_list)
+
+ merged_command_names = [command.name for command in command_list]
+ assert "command1" in merged_command_names
+ assert "command2" in merged_command_names
+ assert len(command_list) == 2
+
+ expected_subcommand_1_args = [ARG_AUTH_TOKEN]
+ expected_subcommand_2_args = [ARG_1, ARG_AUTH_TOKEN]
+ expected_command_2_args = [ARG_1, ARG_2, ARG_AUTH_TOKEN]
+
+ for command in command_list:
+ if command.name == "command1":
+ sub_command_names = [sc.name for sc in
list(command.subcommands)]
+ assert "subcommand1" in sub_command_names
+ assert "subcommand2" in sub_command_names
+ assert len(sub_command_names) == 2
+ for sub_command in command.subcommands:
+ if sub_command.name == "subcommand1":
+ assert sub_command.args == expected_subcommand_1_args
+ if sub_command.name == "subcommand2":
+ assert sub_command.args == expected_subcommand_2_args
+ if command.name == "command2":
+ assert command.args == expected_command_2_args
+
def test_trigger_dag_run_defaults_logical_date_to_now(self):
"""Test that trigger command defaults logical_date to now when not
provided."""
from datetime import datetime, timezone