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 6ae2133176d Add dry-run mode to teams sync (#72494)
6ae2133176d is described below
commit 6ae2133176d49fb8231092ba13e674761da8bd27
Author: SameerMesiah97 <[email protected]>
AuthorDate: Wed Sep 23 00:40:56 2026 +0100
Add dry-run mode to teams sync (#72494)
* Allow airflow teams sync --dry-run to preview missing teams anddefault
team pools derived from the DAG bundle configuration without writing to the
metadata database. Add tests covering dry-run behavior for missing teams,
missing default team pools, and no-op sync scenarios.
* Add no-op test coverage for teams sync dry-run.
---------
Co-authored-by: Sameer Mesiah <[email protected]>
---
airflow-core/newsfragments/72494.feature.rst | 1 +
airflow-core/src/airflow/cli/cli_config.py | 7 +-
.../src/airflow/cli/commands/team_command.py | 47 ++++++---
.../tests/unit/cli/commands/test_team_command.py | 113 +++++++++++++++++++++
4 files changed, 154 insertions(+), 14 deletions(-)
diff --git a/airflow-core/newsfragments/72494.feature.rst
b/airflow-core/newsfragments/72494.feature.rst
new file mode 100644
index 00000000000..4406eb0e89a
--- /dev/null
+++ b/airflow-core/newsfragments/72494.feature.rst
@@ -0,0 +1 @@
+Add ``--dry-run`` to ``airflow teams sync`` so deployments can preview missing
teams and default team pools without writing to the metadata database.
diff --git a/airflow-core/src/airflow/cli/cli_config.py
b/airflow-core/src/airflow/cli/cli_config.py
index 0274fc4402e..1c0913db8c3 100644
--- a/airflow-core/src/airflow/cli/cli_config.py
+++ b/airflow-core/src/airflow/cli/cli_config.py
@@ -375,6 +375,11 @@ ARG_POOL = Arg(("--pool",), "Resource pool to use")
# teams
ARG_TEAM_NAME = Arg(("name",), help="Team name")
+ARG_TEAMS_DRY_RUN = Arg(
+ ("--dry-run",),
+ help="Show what would be synchronized without making changes.",
+ action="store_true",
+)
# backfill
ARG_BACKFILL_DAG = Arg(flags=("--dag-id",), help="The dag to backfill.",
required=True)
@@ -1690,7 +1695,7 @@ TEAMS_COMMANDS = (
help="Sync teams",
description=("Sync missing teams from the dag bundle config into the
database.\n"),
func=lazy_load_command("airflow.cli.commands.team_command.team_sync"),
- args=(ARG_VERBOSE,),
+ args=(ARG_TEAMS_DRY_RUN, ARG_VERBOSE),
),
ActionCommand(
name="verify",
diff --git a/airflow-core/src/airflow/cli/commands/team_command.py
b/airflow-core/src/airflow/cli/commands/team_command.py
index 5f739e475ce..630141cfd86 100644
--- a/airflow-core/src/airflow/cli/commands/team_command.py
+++ b/airflow-core/src/airflow/cli/commands/team_command.py
@@ -234,23 +234,44 @@ def team_sync(args, *, session=NEW_SESSION):
"Names already stored must be corrected before syncing."
)
- teams_added = 0
+ teams_to_add = sorted(dag_bundle_teams - existing_teams)
+ pools_to_add = []
+
+ for team_name in dag_bundle_teams:
+ pool = session.scalar(
+ select(Pool).where(
+ Pool.pool == Pool.get_default_team_pool_name(team_name),
+ Pool.team_name == team_name,
+ )
+ )
+
+ if pool is None:
+ pools_to_add.append(Pool.get_default_team_pool_name(team_name))
+
+ if args.dry_run:
+ if not teams_to_add and not pools_to_add:
+ print("No changes to sync.")
+ return
+
+ if teams_to_add:
+ print("Teams to add:")
+ for team_name in teams_to_add:
+ print(f" - {team_name}")
+
+ if pools_to_add:
+ print("Default team pools to add:")
+ for pool_name in sorted(pools_to_add):
+ print(f" - {pool_name}")
+
+ return
try:
for team_name in dag_bundle_teams:
- if team_name not in existing_teams:
+ if team_name in teams_to_add:
session.add(Team(name=team_name))
session.flush()
- teams_added += 1
-
- pool = session.scalar(
- select(Pool).where(
- Pool.pool == Pool.get_default_team_pool_name(team_name),
- Pool.team_name == team_name,
- )
- )
- if pool is None:
+ if Pool.get_default_team_pool_name(team_name) in pools_to_add:
_create_default_team_pool(team_name=team_name, session=session)
session.commit()
@@ -258,8 +279,8 @@ def team_sync(args, *, session=NEW_SESSION):
session.rollback()
raise SystemExit(f"Failed to sync teams: {e}")
- if teams_added > 0:
- print(f"{teams_added} teams added.")
+ if teams_to_add:
+ print(f"{len(teams_to_add)} teams added.")
@cli_utils.action_cli
diff --git a/airflow-core/tests/unit/cli/commands/test_team_command.py
b/airflow-core/tests/unit/cli/commands/test_team_command.py
index 4f1889dd383..c96b44b3c91 100644
--- a/airflow-core/tests/unit/cli/commands/test_team_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_team_command.py
@@ -599,6 +599,119 @@ class TestCliTeams:
assert "Verification succeeded." in stdout.getvalue()
+ def test_team_sync_dry_run_reports_changes_without_writing(self,
stdout_capture):
+ bundle_config = [
+ {
+ "name": "bundleone",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": "/dev/null", "refresh_interval": 0},
+ "team_name": "team1",
+ },
+ {
+ "name": "bundletwo",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": "/dev/null", "refresh_interval": 300},
+ "team_name": "team2",
+ },
+ ]
+
+ with conf_vars(
+ {
+ ("core", "multi_team"): "True",
+ ("dag_processor", "dag_bundle_config_list"):
json.dumps(bundle_config),
+ }
+ ):
+ with stdout_capture as stdout:
+ team_command.team_sync(self.parser.parse_args(["teams",
"sync", "--dry-run"]))
+
+ output = stdout.getvalue()
+
+ assert "Teams to add:" in output
+ assert "team1" in output
+ assert "team2" in output
+ assert "Default team pools to add:" in output
+ assert Pool.get_default_team_pool_name("team1") in output
+ assert Pool.get_default_team_pool_name("team2") in output
+
+ assert self.session.scalars(select(Team)).all() == []
+ assert (
+ self.session.scalar(select(Pool).where(Pool.pool ==
Pool.get_default_team_pool_name("team1")))
+ is None
+ )
+ assert (
+ self.session.scalar(select(Pool).where(Pool.pool ==
Pool.get_default_team_pool_name("team2")))
+ is None
+ )
+
+ def
test_team_sync_dry_run_reports_missing_default_pool_without_writing(self,
stdout_capture):
+ bundle_config = [
+ {
+ "name": "bundleone",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": "/dev/null", "refresh_interval": 0},
+ "team_name": "team1",
+ },
+ ]
+
+ self.session.add(Team(name="team1"))
+ self.session.commit()
+
+ with conf_vars(
+ {
+ ("core", "multi_team"): "True",
+ ("dag_processor", "dag_bundle_config_list"):
json.dumps(bundle_config),
+ }
+ ):
+ with stdout_capture as stdout:
+ team_command.team_sync(self.parser.parse_args(["teams",
"sync", "--dry-run"]))
+
+ output = stdout.getvalue()
+
+ assert "Teams to add:" not in output
+ assert "Default team pools to add:" in output
+ assert Pool.get_default_team_pool_name("team1") in output
+
+ assert self.session.scalar(select(Team).where(Team.name == "team1"))
is not None
+ assert (
+ self.session.scalar(select(Pool).where(Pool.pool ==
Pool.get_default_team_pool_name("team1")))
+ is None
+ )
+
+ def test_team_sync_dry_run_reports_no_changes(self, stdout_capture):
+ bundle_config = [
+ {
+ "name": "bundleone",
+ "classpath":
"airflow.dag_processing.bundles.local.LocalDagBundle",
+ "kwargs": {"path": "/dev/null", "refresh_interval": 0},
+ "team_name": "team1",
+ },
+ ]
+
+ self.session.add(Team(name="team1"))
+ self.session.commit()
+
+ self.session.add(
+ Pool(
+ pool=Pool.get_default_team_pool_name("team1"),
+ slots=128,
+ description="Default pool",
+ include_deferred=False,
+ team_name="team1",
+ )
+ )
+ self.session.commit()
+
+ with conf_vars(
+ {
+ ("core", "multi_team"): "True",
+ ("dag_processor", "dag_bundle_config_list"):
json.dumps(bundle_config),
+ }
+ ):
+ with stdout_capture as stdout:
+ team_command.team_sync(self.parser.parse_args(["teams",
"sync", "--dry-run"]))
+
+ assert "No changes to sync." in stdout.getvalue()
+
def test_team_verify_missing_default_pool(self):
self.session.add(Team(name="team1"))
self.session.commit()