From faeac09efd0603e232d8bd35526433a1e7aefeb3 Mon Sep 17 00:00:00 2001 From: "Guan-Ming (Wesley) Chiu" <105915352+guan404ming@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:12:43 +0800 Subject: [PATCH 1/8] Add deny_dag_run_types option to limit specific dag run types Signed-off-by: Guan-Ming (Wesley) Chiu <105915352+guan404ming@users.noreply.github.com> --- airflow-core/docs/migrations-ref.rst | 4 +- .../src/airflow/api/common/trigger_dag.py | 3 + .../api_fastapi/core_api/datamodels/dags.py | 2 + .../core_api/openapi/_private_ui.yaml | 8 +++ .../openapi/v2-rest-api-generated.yaml | 16 +++++ .../core_api/routes/public/assets.py | 8 +++ .../core_api/routes/public/backfills.py | 6 ++ .../core_api/routes/public/dag_run.py | 6 ++ .../execution_api/routes/dag_runs.py | 11 +++- .../src/airflow/dag_processing/collection.py | 7 +++ .../src/airflow/jobs/scheduler_job_runner.py | 29 +++++++++ ...104_3_2_0_add_deny_dag_run_types_to_dag.py | 50 +++++++++++++++ airflow-core/src/airflow/models/backfill.py | 16 +++++ airflow-core/src/airflow/models/dag.py | 1 + .../airflow/serialization/definitions/dag.py | 2 + .../src/airflow/serialization/schema.json | 6 ++ .../serialization/serialized_objects.py | 10 +++ .../ui/openapi-gen/requests/schemas.gen.ts | 48 +++++++++++++- .../ui/openapi-gen/requests/types.gen.ts | 3 + .../ui/public/i18n/locales/en/components.json | 1 + .../TriggerDag/TriggerDAGButton.tsx | 38 +++++++---- .../ui/src/layouts/Details/DetailsLayout.tsx | 1 + .../ui/src/pages/DagsList/DagCard.test.tsx | 1 + .../airflow/ui/src/pages/DagsList/DagCard.tsx | 1 + .../ui/src/pages/DagsList/DagsList.tsx | 1 + airflow-core/src/airflow/utils/db.py | 2 +- .../tests/unit/api/common/test_trigger_dag.py | 63 +++++++++++++++++++ .../core_api/routes/public/test_assets.py | 10 +++ .../core_api/routes/public/test_backfills.py | 28 +++++++++ .../core_api/routes/public/test_dag_run.py | 19 ++++++ .../versions/head/test_dag_runs.py | 27 ++++++++ .../serialization/test_dag_serialization.py | 43 +++++++++++++ .../airflowctl/api/datamodels/generated.py | 2 + task-sdk/src/airflow/sdk/definitions/dag.py | 13 ++++ .../tests/task_sdk/definitions/test_dag.py | 14 +++++ 35 files changed, 481 insertions(+), 19 deletions(-) create mode 100644 airflow-core/src/airflow/migrations/versions/0104_3_2_0_add_deny_dag_run_types_to_dag.py create mode 100644 airflow-core/tests/unit/api/common/test_trigger_dag.py diff --git a/airflow-core/docs/migrations-ref.rst b/airflow-core/docs/migrations-ref.rst index b0d6248e1a5f9..e4d2c43614396 100644 --- a/airflow-core/docs/migrations-ref.rst +++ b/airflow-core/docs/migrations-ref.rst @@ -39,7 +39,9 @@ Here's the list of all the Database Migrations that are executed via when you ru +-------------------------+------------------+-------------------+--------------------------------------------------------------+ | Revision ID | Revises ID | Airflow Version | Description | +=========================+==================+===================+==============================================================+ -| ``f8c9d7e6b5a4`` (head) | ``53ff648b8a26`` | ``3.2.0`` | Standardize UUID column format for non-PostgreSQL databases. | +| ``e42d9fcd10d9`` (head) | ``f8c9d7e6b5a4`` | ``3.2.0`` | add deny_dag_run_types to dag. | ++-------------------------+------------------+-------------------+--------------------------------------------------------------+ +| ``f8c9d7e6b5a4`` | ``53ff648b8a26`` | ``3.2.0`` | Standardize UUID column format for non-PostgreSQL databases. | +-------------------------+------------------+-------------------+--------------------------------------------------------------+ | ``53ff648b8a26`` | ``a5a3e5eb9b8d`` | ``3.2.0`` | Add revoked_token table. | +-------------------------+------------------+-------------------+--------------------------------------------------------------+ diff --git a/airflow-core/src/airflow/api/common/trigger_dag.py b/airflow-core/src/airflow/api/common/trigger_dag.py index 77912f58d2637..33e990079e2fc 100644 --- a/airflow-core/src/airflow/api/common/trigger_dag.py +++ b/airflow-core/src/airflow/api/common/trigger_dag.py @@ -158,6 +158,9 @@ def trigger_dag( if dag_model is None: raise DagNotFound(f"Dag id {dag_id} not found in DagModel") + if dag_model.deny_dag_run_types and DagRunType.MANUAL.value in dag_model.deny_dag_run_types: + raise ValueError(f"DAG with dag_id: '{dag_id}' does not allow manual runs") + dagbag = DBDagBag() dr = _trigger_dag( dag_id=dag_id, diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py index b56f92262c39c..439009a1f33c3 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py @@ -37,6 +37,7 @@ from airflow.api_fastapi.core_api.datamodels.dag_versions import DagVersionResponse from airflow.configuration import conf from airflow.models.dag_version import DagVersion +from airflow.utils.types import DagRunType if TYPE_CHECKING: from airflow.serialization.definitions.param import SerializedParamsDict @@ -83,6 +84,7 @@ class DAGResponse(BaseModel): next_dagrun_data_interval_start: datetime | None next_dagrun_data_interval_end: datetime | None next_dagrun_run_after: datetime | None + deny_dag_run_types: list[DagRunType] | None owners: list[str] @field_serializer("tags") diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml index 72832270c4d54..2f087e4cbdb38 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml @@ -1743,6 +1743,13 @@ components: format: date-time - type: 'null' title: Next Dagrun Run After + deny_dag_run_types: + anyOf: + - items: + $ref: '#/components/schemas/DagRunType' + type: array + - type: 'null' + title: Deny Dag Run Types owners: items: type: string @@ -1798,6 +1805,7 @@ components: - next_dagrun_data_interval_start - next_dagrun_data_interval_end - next_dagrun_run_after + - deny_dag_run_types - owners - asset_expression - latest_dag_runs diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml index d3af3d8674f92..95970c919660f 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml @@ -10240,6 +10240,13 @@ components: format: date-time - type: 'null' title: Next Dagrun Run After + deny_dag_run_types: + anyOf: + - items: + $ref: '#/components/schemas/DagRunType' + type: array + - type: 'null' + title: Deny Dag Run Types owners: items: type: string @@ -10376,6 +10383,7 @@ components: - next_dagrun_data_interval_start - next_dagrun_data_interval_end - next_dagrun_run_after + - deny_dag_run_types - owners - catchup - dag_run_timeout @@ -10516,6 +10524,13 @@ components: format: date-time - type: 'null' title: Next Dagrun Run After + deny_dag_run_types: + anyOf: + - items: + $ref: '#/components/schemas/DagRunType' + type: array + - type: 'null' + title: Deny Dag Run Types owners: items: type: string @@ -10552,6 +10567,7 @@ components: - next_dagrun_data_interval_start - next_dagrun_data_interval_end - next_dagrun_run_after + - deny_dag_run_types - owners - file_token title: DAGResponse diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py index 3e5108f14472c..87feaae14f241 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/assets.py @@ -74,6 +74,7 @@ AssetWatcherModel, TaskOutletAssetReference, ) +from airflow.models.dag import DagModel from airflow.typing_compat import Unpack from airflow.utils.state import DagRunState from airflow.utils.types import DagRunTriggeredByType, DagRunType @@ -402,6 +403,13 @@ def materialize_asset( f"More than one DAG materializes asset with ID: {asset_id}", ) + dm = session.scalar(select(DagModel).where(DagModel.dag_id == dag_id).limit(1)) + if dm and dm.deny_dag_run_types and DagRunType.MANUAL.value in dm.deny_dag_run_types: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"DAG with dag_id: '{dag_id}' does not allow manual runs", + ) + dag = get_latest_version_of_dag(dag_bag, dag_id, session) return dag.create_dagrun( run_id=dag.timetable.generate_run_id( diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/backfills.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/backfills.py index b106248f136ed..883ba66648a6f 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/backfills.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/backfills.py @@ -50,6 +50,7 @@ Backfill, BackfillDagRun, DagNoScheduleException, + DeniedDagRunType, InvalidBackfillDate, InvalidBackfillDirection, InvalidReprocessBehavior, @@ -252,6 +253,11 @@ def create_backfill( status_code=status.HTTP_404_NOT_FOUND, detail=f"Could not find dag {backfill_request.dag_id}", ) + except DeniedDagRunType as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) except ( InvalidReprocessBehavior, InvalidBackfillDirection, diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py index c1f9d6626b7c7..c4bc411159d18 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_run.py @@ -459,6 +459,12 @@ def trigger_dag_run( f"DAG with dag_id: '{dag_id}' has import errors and cannot be triggered", ) + if dm.deny_dag_run_types and DagRunType.MANUAL.value in dm.deny_dag_run_types: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"DAG with dag_id: '{dag_id}' does not allow manual runs", + ) + referer = request.headers.get("referer") if referer: triggered_by = DagRunTriggeredByType.UI diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/routes/dag_runs.py b/airflow-core/src/airflow/api_fastapi/execution_api/routes/dag_runs.py index f0af063b76fb7..6ddbeff1905cb 100644 --- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/dag_runs.py +++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/dag_runs.py @@ -36,7 +36,7 @@ from airflow.models.dag import DagModel from airflow.models.dagrun import DagRun as DagRunModel from airflow.utils.state import DagRunState -from airflow.utils.types import DagRunTriggeredByType +from airflow.utils.types import DagRunTriggeredByType, DagRunType router = VersionedAPIRouter() @@ -112,6 +112,15 @@ def trigger_dag_run( }, ) + if dm.deny_dag_run_types and DagRunType.MANUAL.value in dm.deny_dag_run_types: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={ + "reason": "denied_run_type", + "message": f"Dag with dag_id '{dag_id}' does not allow manual runs", + }, + ) + try: # todo: AIP-76 add partition key here # https://github.com/apache/airflow/issues/61075 diff --git a/airflow-core/src/airflow/dag_processing/collection.py b/airflow-core/src/airflow/dag_processing/collection.py index 5abfbd8ae7525..b382596146367 100644 --- a/airflow-core/src/airflow/dag_processing/collection.py +++ b/airflow-core/src/airflow/dag_processing/collection.py @@ -27,6 +27,7 @@ from __future__ import annotations +import enum import traceback from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar @@ -600,6 +601,12 @@ def update_dags( dm.timetable_description = dag.timetable.description dm.fail_fast = dag.fail_fast if dag.fail_fast is not None else False + deny_types = dag.deny_dag_run_types + if deny_types: + dm.deny_dag_run_types = sorted(v.value if isinstance(v, enum.Enum) else v for v in deny_types) + else: + dm.deny_dag_run_types = None + dm.bundle_name = self.bundle_name dm.bundle_version = self.bundle_version diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index 9b77ce4592ca3..19bee59fab516 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -1808,6 +1808,18 @@ def _create_dagruns_for_partitioned_asset_dags(self, session: Session) -> set[st self.log.error("Dag '%s' not found in serialized_dag table", apdr.target_dag_id) continue + dag_model = session.scalar(select(DagModel).where(DagModel.dag_id == apdr.target_dag_id).limit(1)) + if ( + dag_model + and dag_model.deny_dag_run_types + and DagRunType.ASSET_TRIGGERED.value in dag_model.deny_dag_run_types + ): + self.log.warning( + "DAG does not allow asset-triggered runs; skipping", + dag_id=apdr.target_dag_id, + ) + continue + asset_models = session.scalars( select(AssetModel).where( exists( @@ -1986,6 +1998,13 @@ def _create_dag_runs(self, dag_models: Collection[DagModel], session: Session) - dag_model.calculate_dagrun_date_fields(dag=serdag, last_automated_run=dr) continue + if dag_model.deny_dag_run_types and DagRunType.SCHEDULED.value in dag_model.deny_dag_run_types: + self.log.warning( + "DAG does not allow scheduled runs; skipping", + dag_id=dag_model.dag_id, + ) + continue + try: next_info = serdag.timetable.next_run_info_from_dag_model(dag_model=dag_model) data_interval = next_info.data_interval @@ -2058,6 +2077,16 @@ def _create_dag_runs_asset_triggered( ) continue + if ( + dag_model.deny_dag_run_types + and DagRunType.ASSET_TRIGGERED.value in dag_model.deny_dag_run_types + ): + self.log.warning( + "DAG does not allow asset-triggered runs; skipping", + dag_id=dag_model.dag_id, + ) + continue + triggered_date = triggered_dates[dag.dag_id] cte = ( select(func.max(DagRun.run_after).label("previous_dag_run_run_after")) diff --git a/airflow-core/src/airflow/migrations/versions/0104_3_2_0_add_deny_dag_run_types_to_dag.py b/airflow-core/src/airflow/migrations/versions/0104_3_2_0_add_deny_dag_run_types_to_dag.py new file mode 100644 index 0000000000000..df9d4539f7ba8 --- /dev/null +++ b/airflow-core/src/airflow/migrations/versions/0104_3_2_0_add_deny_dag_run_types_to_dag.py @@ -0,0 +1,50 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +add deny_dag_run_types to dag. + +Revision ID: e42d9fcd10d9 +Revises: f8c9d7e6b5a4 +Create Date: 2026-02-12 11:49:40.753440 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e42d9fcd10d9" +down_revision = "f8c9d7e6b5a4" +branch_labels = None +depends_on = None +airflow_version = "3.2.0" + + +def upgrade(): + """Add deny_dag_run_types column to dag table.""" + with op.batch_alter_table("dag", schema=None) as batch_op: + batch_op.add_column(sa.Column("deny_dag_run_types", sa.JSON(), nullable=True)) + + +def downgrade(): + """Remove deny_dag_run_types column from dag table.""" + with op.batch_alter_table("dag", schema=None) as batch_op: + batch_op.drop_column("deny_dag_run_types") diff --git a/airflow-core/src/airflow/models/backfill.py b/airflow-core/src/airflow/models/backfill.py index 3064ce2dfe6ed..4d149e9928e50 100644 --- a/airflow-core/src/airflow/models/backfill.py +++ b/airflow-core/src/airflow/models/backfill.py @@ -100,6 +100,14 @@ class InvalidBackfillDate(AirflowException): """ +class DeniedDagRunType(AirflowException): + """ + Raised when a DAG does not allow the requested run type. + + :meta private: + """ + + class UnknownActiveBackfills(AirflowException): """ Raised when the quantity of active backfills cannot be determined. @@ -502,6 +510,14 @@ def _create_backfill( if not serdag: raise DagNotFound(f"Could not find dag {dag_id}") + dag_model = session.scalar(select(DagModel).where(DagModel.dag_id == dag_id).limit(1)) + if ( + dag_model + and dag_model.deny_dag_run_types + and DagRunType.BACKFILL_JOB.value in dag_model.deny_dag_run_types + ): + raise DeniedDagRunType(f"DAG with dag_id: '{dag_id}' does not allow backfill runs") + no_schedule = session.scalar( select(func.count()).where(DagModel.timetable_summary == "None", DagModel.dag_id == dag_id) ) diff --git a/airflow-core/src/airflow/models/dag.py b/airflow-core/src/airflow/models/dag.py index e74ceccae18c6..52419cdcc5891 100644 --- a/airflow-core/src/airflow/models/dag.py +++ b/airflow-core/src/airflow/models/dag.py @@ -404,6 +404,7 @@ class DagModel(Base): has_task_concurrency_limits: Mapped[bool] = mapped_column(Boolean, nullable=False) has_import_errors: Mapped[bool] = mapped_column(Boolean(), default=False, server_default="0") fail_fast: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0") + deny_dag_run_types: Mapped[list[str] | None] = mapped_column(sa.JSON(), nullable=True) # The logical date of the next dag run. next_dagrun: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py b/airflow-core/src/airflow/serialization/definitions/dag.py index b1a2dc8da594a..ffb8334689439 100644 --- a/airflow-core/src/airflow/serialization/definitions/dag.py +++ b/airflow-core/src/airflow/serialization/definitions/dag.py @@ -100,6 +100,7 @@ class SerializedDAG: dagrun_timeout: datetime.timedelta | None = None deadline: list[str] | None = None default_args: dict[str, Any] = attrs.field(factory=dict) + deny_dag_run_types: list[str] | None = None description: str | None = None disable_bundle_versioning: bool = False doc_md: str | None = None @@ -148,6 +149,7 @@ def get_serialized_fields(cls) -> frozenset[str]: "dagrun_timeout", "deadline", "default_args", + "deny_dag_run_types", "description", "disable_bundle_versioning", "doc_md", diff --git a/airflow-core/src/airflow/serialization/schema.json b/airflow-core/src/airflow/serialization/schema.json index 6058275f35c05..8f0a9854e97f7 100644 --- a/airflow-core/src/airflow/serialization/schema.json +++ b/airflow-core/src/airflow/serialization/schema.json @@ -176,6 +176,12 @@ } }, "catchup": { "type": "boolean", "default": false }, + "deny_dag_run_types": { + "anyOf": [ + { "type": "array", "items": { "type": "string" } }, + { "type": "null" } + ] + }, "fail_fast": { "type": "boolean", "default": false }, "fileloc": { "type" : "string"}, "relative_fileloc": { "type" : "string"}, diff --git a/airflow-core/src/airflow/serialization/serialized_objects.py b/airflow-core/src/airflow/serialization/serialized_objects.py index f1e5813b1893e..18d5a5f853795 100644 --- a/airflow-core/src/airflow/serialization/serialized_objects.py +++ b/airflow-core/src/airflow/serialization/serialized_objects.py @@ -1713,6 +1713,13 @@ def serialize_dag(cls, dag: DAG) -> dict: else: serialized_dag["deadline"] = None + if dag.deny_dag_run_types: + serialized_dag["deny_dag_run_types"] = sorted( + v.value if isinstance(v, enum.Enum) else v for v in dag.deny_dag_run_types + ) + else: + serialized_dag["deny_dag_run_types"] = None + # Edge info in the JSON exactly matches our internal structure serialized_dag["edge_info"] = dag.edge_info serialized_dag["params"] = cls._serialize_params_dict(dag.params) @@ -1810,6 +1817,8 @@ def _deserialize_dag_internal( v = cls._deserialize_params_dict(v) elif k == "tags": v = set(v) + elif k == "deny_dag_run_types": + v = frozenset(v) if v else None # else use v as it is object.__setattr__(dag, k, v) @@ -2210,6 +2219,7 @@ class LazyDeserializedDAG(pydantic.BaseModel): "max_consecutive_failed_dag_runs", "dagrun_timeout", "deadline", + "deny_dag_run_types", "catchup", "doc_md", "access_control", diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts index a533f31c31f4a..4155e615617a9 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts @@ -1963,6 +1963,20 @@ export const $DAGDetailsResponse = { ], title: 'Next Dagrun Run After' }, + deny_dag_run_types: { + anyOf: [ + { + items: { + '$ref': '#/components/schemas/DagRunType' + }, + type: 'array' + }, + { + type: 'null' + } + ], + title: 'Deny Dag Run Types' + }, owners: { items: { type: 'string' @@ -2162,7 +2176,7 @@ Deprecated: Use max_active_tasks instead.`, } }, type: 'object', - required: ['dag_id', 'dag_display_name', 'is_paused', 'is_stale', 'last_parsed_time', 'last_parse_duration', 'last_expired', 'bundle_name', 'bundle_version', 'relative_fileloc', 'fileloc', 'description', 'timetable_summary', 'timetable_description', 'tags', 'max_active_tasks', 'max_active_runs', 'max_consecutive_failed_dag_runs', 'has_task_concurrency_limits', 'has_import_errors', 'next_dagrun_logical_date', 'next_dagrun_data_interval_start', 'next_dagrun_data_interval_end', 'next_dagrun_run_after', 'owners', 'catchup', 'dag_run_timeout', 'asset_expression', 'doc_md', 'start_date', 'end_date', 'is_paused_upon_creation', 'params', 'render_template_as_native_obj', 'template_search_path', 'timezone', 'last_parsed', 'default_args', 'file_token', 'concurrency', 'latest_dag_version'], + required: ['dag_id', 'dag_display_name', 'is_paused', 'is_stale', 'last_parsed_time', 'last_parse_duration', 'last_expired', 'bundle_name', 'bundle_version', 'relative_fileloc', 'fileloc', 'description', 'timetable_summary', 'timetable_description', 'tags', 'max_active_tasks', 'max_active_runs', 'max_consecutive_failed_dag_runs', 'has_task_concurrency_limits', 'has_import_errors', 'next_dagrun_logical_date', 'next_dagrun_data_interval_start', 'next_dagrun_data_interval_end', 'next_dagrun_run_after', 'deny_dag_run_types', 'owners', 'catchup', 'dag_run_timeout', 'asset_expression', 'doc_md', 'start_date', 'end_date', 'is_paused_upon_creation', 'params', 'render_template_as_native_obj', 'template_search_path', 'timezone', 'last_parsed', 'default_args', 'file_token', 'concurrency', 'latest_dag_version'], title: 'DAGDetailsResponse', description: 'Specific serializer for DAG Details responses.' } as const; @@ -2386,6 +2400,20 @@ export const $DAGResponse = { ], title: 'Next Dagrun Run After' }, + deny_dag_run_types: { + anyOf: [ + { + items: { + '$ref': '#/components/schemas/DagRunType' + }, + type: 'array' + }, + { + type: 'null' + } + ], + title: 'Deny Dag Run Types' + }, owners: { items: { type: 'string' @@ -2401,7 +2429,7 @@ export const $DAGResponse = { } }, type: 'object', - required: ['dag_id', 'dag_display_name', 'is_paused', 'is_stale', 'last_parsed_time', 'last_parse_duration', 'last_expired', 'bundle_name', 'bundle_version', 'relative_fileloc', 'fileloc', 'description', 'timetable_summary', 'timetable_description', 'tags', 'max_active_tasks', 'max_active_runs', 'max_consecutive_failed_dag_runs', 'has_task_concurrency_limits', 'has_import_errors', 'next_dagrun_logical_date', 'next_dagrun_data_interval_start', 'next_dagrun_data_interval_end', 'next_dagrun_run_after', 'owners', 'file_token'], + required: ['dag_id', 'dag_display_name', 'is_paused', 'is_stale', 'last_parsed_time', 'last_parse_duration', 'last_expired', 'bundle_name', 'bundle_version', 'relative_fileloc', 'fileloc', 'description', 'timetable_summary', 'timetable_description', 'tags', 'max_active_tasks', 'max_active_runs', 'max_consecutive_failed_dag_runs', 'has_task_concurrency_limits', 'has_import_errors', 'next_dagrun_logical_date', 'next_dagrun_data_interval_start', 'next_dagrun_data_interval_end', 'next_dagrun_run_after', 'deny_dag_run_types', 'owners', 'file_token'], title: 'DAGResponse', description: 'DAG serializer for responses.' } as const; @@ -7703,6 +7731,20 @@ export const $DAGWithLatestDagRunsResponse = { ], title: 'Next Dagrun Run After' }, + deny_dag_run_types: { + anyOf: [ + { + items: { + '$ref': '#/components/schemas/DagRunType' + }, + type: 'array' + }, + { + type: 'null' + } + ], + title: 'Deny Dag Run Types' + }, owners: { items: { type: 'string' @@ -7748,7 +7790,7 @@ export const $DAGWithLatestDagRunsResponse = { } }, type: 'object', - required: ['dag_id', 'dag_display_name', 'is_paused', 'is_stale', 'last_parsed_time', 'last_parse_duration', 'last_expired', 'bundle_name', 'bundle_version', 'relative_fileloc', 'fileloc', 'description', 'timetable_summary', 'timetable_description', 'tags', 'max_active_tasks', 'max_active_runs', 'max_consecutive_failed_dag_runs', 'has_task_concurrency_limits', 'has_import_errors', 'next_dagrun_logical_date', 'next_dagrun_data_interval_start', 'next_dagrun_data_interval_end', 'next_dagrun_run_after', 'owners', 'asset_expression', 'latest_dag_runs', 'pending_actions', 'is_favorite', 'file_token'], + required: ['dag_id', 'dag_display_name', 'is_paused', 'is_stale', 'last_parsed_time', 'last_parse_duration', 'last_expired', 'bundle_name', 'bundle_version', 'relative_fileloc', 'fileloc', 'description', 'timetable_summary', 'timetable_description', 'tags', 'max_active_tasks', 'max_active_runs', 'max_consecutive_failed_dag_runs', 'has_task_concurrency_limits', 'has_import_errors', 'next_dagrun_logical_date', 'next_dagrun_data_interval_start', 'next_dagrun_data_interval_end', 'next_dagrun_run_after', 'deny_dag_run_types', 'owners', 'asset_expression', 'latest_dag_runs', 'pending_actions', 'is_favorite', 'file_token'], title: 'DAGWithLatestDagRunsResponse', description: 'DAG with latest dag runs response serializer.' } as const; diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts index 7e2b42bdb5e0b..81a666db4faa9 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts @@ -550,6 +550,7 @@ export type DAGDetailsResponse = { next_dagrun_data_interval_start: string | null; next_dagrun_data_interval_end: string | null; next_dagrun_run_after: string | null; + deny_dag_run_types: Array | null; owners: Array<(string)>; catchup: boolean; dag_run_timeout: string | null; @@ -627,6 +628,7 @@ export type DAGResponse = { next_dagrun_data_interval_start: string | null; next_dagrun_data_interval_end: string | null; next_dagrun_run_after: string | null; + deny_dag_run_types: Array | null; owners: Array<(string)>; /** * Return file token. @@ -1899,6 +1901,7 @@ export type DAGWithLatestDagRunsResponse = { next_dagrun_data_interval_start: string | null; next_dagrun_data_interval_end: string | null; next_dagrun_run_after: string | null; + deny_dag_run_types: Array | null; owners: Array<(string)>; asset_expression: { [key: string]: unknown; diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json index f8f3b666c7e0e..251fabc8c9459 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json @@ -130,6 +130,7 @@ "intervalStart": "Start", "loading": "Loading Dag information...", "loadingFailed": "Failed to load Dag information. Please try again.", + "manualRunDenied": "Manual runs are not allowed for this DAG", "runIdHelp": "Optional - will be generated if not provided", "selectDescription": "Trigger a single run of this Dag", "selectLabel": "Single Run", diff --git a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx index 83308ca4a267a..697fab79a71ad 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx @@ -17,6 +17,7 @@ * under the License. */ import { Box, Button, IconButton, useDisclosure } from "@chakra-ui/react"; +import type { DagRunType } from "openapi-gen/requests/types.gen"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { FiPlay } from "react-icons/fi"; @@ -31,6 +32,7 @@ import TriggerDAGModal from "./TriggerDAGModal"; type TriggerDAGButtonProps = { readonly dagDisplayName: string; readonly dagId: string; + readonly denyDagRunTypes?: Array | null; readonly isPaused: boolean; readonly variant?: "ghost" | "outline"; readonly withText?: boolean; @@ -39,10 +41,12 @@ type TriggerDAGButtonProps = { export const TriggerDAGButton = ({ dagDisplayName, dagId, + denyDagRunTypes, isPaused, variant = "ghost", withText = false, }: TriggerDAGButtonProps) => { + const isManualRunDenied = Boolean(denyDagRunTypes?.includes("manual")); const { onClose, onOpen, open } = useDisclosure(); const { t: translate } = useTranslation("components"); const { runId } = useParams(); @@ -91,18 +95,21 @@ export const TriggerDAGButton = ({ return ( - - - + + + + + {translate("triggerDag.button")} @@ -128,12 +135,16 @@ export const TriggerDAGButton = ({ // Normal trigger button without menu return ( <> - + {withText ? (