Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions airflow-ctl-tests/tests/airflowctl_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import sys

import pytest
import requests
from python_on_whales import DockerClient, docker

from airflowctl_tests import console
Expand All @@ -35,16 +36,31 @@
from tests_common.test_utils.fernet import generate_fernet_key_string


@pytest.fixture(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}"

Expand Down Expand Up @@ -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"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@

import pytest

from airflowctl_tests.constants import LOGIN_COMMAND


def date_param():
import random
Expand All @@ -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",
Expand Down Expand Up @@ -80,22 +79,22 @@ def date_param():
"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
Expand Down Expand Up @@ -124,11 +123,38 @@ def date_param():
"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)


@pytest.mark.flaky(reruns=3, reruns_delay=1)
@pytest.mark.parametrize(
"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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion airflow-ctl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 9 additions & 3 deletions airflow-ctl/docs/howto/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions airflow-ctl/docs/images/command_hashes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ jobs:7f8680afff230eb9940bc7fca727bd52
pools:03fc7d948cbecf16ff8d640eb8f0ce43
providers:1c0afb2dff31d93ab2934b032a2250ab
variables:0354f8f4b0dde1c3771ed1568692c6ae
version:d4a7a6229b3a204f114283b62eac789b
auth login:5277c653ff6dce51f37472dc0bda9775
version:31f4efdf8de0dbaaa4fac71ff7efecc3
auth login:f85e04072626ab4ae17ad17e4a077bf2
Loading
Loading