o-nikolas commented on code in PR #76260:
URL: https://github.com/apache/airflow/pull/76260#discussion_r4448689964
##########
airflow-core/src/airflow/api_fastapi/core_api/security.py:
##########
@@ -1221,6 +1222,52 @@ def depends_permitted_assets_filter(
]
+def _triggers_watching(asset_ids: set[int]) -> Select:
+ # Uncorrelated, as in Trigger.owner_still_refers_expression.
+ return
select(AssetWatcherModel.trigger_id).where(AssetWatcherModel.asset_id.in_(asset_ids))
+
+
+class PermittedTriggerFilter(OrmClause[None]):
+ """
+ Keep the triggers whose owner the caller can see, by one rule for every
kind of trigger.
+
+ A trigger's owner is the Dag recorded on it (the Dag of its task instance
or callback) or the assets its
+ watchers watch. Team access is the auth manager's, through those Dags and
assets.
+ """
+
+ def __init__(self, *, dag_ids: set[str], asset_ids: set[int]) -> None:
+ super().__init__()
+ self.dag_ids = dag_ids
+ self.asset_ids = asset_ids
+
+ def to_orm(self, select: Select) -> Select:
+ return select.where(
+ or_(Trigger.dag_id.in_(self.dag_ids),
Trigger.id.in_(_triggers_watching(self.asset_ids)))
+ )
+
+
+def get_readable_asset_ids(user: BaseUser, auth_manager: BaseAuthManager) ->
set[int]:
+ """
+ Return the ids of the assets the user may read, gated like ``GET /assets``
first.
+
+ ``get_authorized_assets`` may assume that gate has passed: the FAB auth
manager, which has no per-asset
+ permissions, returns every asset there.
+ """
+ if not auth_manager.is_authorized_asset(method="GET",
details=AssetDetails(id=None), user=user):
+ return set()
+ return auth_manager.get_authorized_assets(user=user, method="GET")
+
+
+def readable_triggers_filter(user: GetUserDep, auth_manager: AuthManagerDep)
-> PermittedTriggerFilter:
Review Comment:
This only checks Dag read. A task trigger returns task instance data (state,
try number) and last_error, which elsewhere need Task Instance read: the TI
list route uses requires_access_dag("GET", TASK_INSTANCE) (task_instances.py
L559). Callback data is gated on RUN in routes/ui/deadlines.py L81. So a role
with Dag read but no Task Instance read can now see it through /triggers. Could
you gate task triggers on TASK_INSTANCE and callback triggers on RUN, like
those routes do? That would also stop the permission tables listing these
routes as "Public" (api_permissions_ref.rst L594/L598, FAB access-control.rst
L767/L771).
##########
airflow-core/src/airflow/api_fastapi/core_api/routes/public/triggers.py:
##########
@@ -0,0 +1,275 @@
+# 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.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated
+
+from fastapi import Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.orm import contains_eager, defer, selectinload
+
+from airflow.api_fastapi.common.db.common import SessionDep, paginated_select
+from airflow.api_fastapi.common.parameters.base import QueryLimit, QueryOffset
+from airflow.api_fastapi.common.parameters.sort import SortParam
+from airflow.api_fastapi.common.parameters.trigger import (
+ QueryTriggerAssetIdFilter,
+ QueryTriggerDagIdFilter,
+ QueryTriggererHostnameFilter,
+ QueryTriggerKindFilter,
+ QueryTriggerStatusFilter,
+ QueryTriggerTeamsFilter,
+)
+from airflow.api_fastapi.common.router import AirflowRouter
+from airflow.api_fastapi.core_api.datamodels.trigger import (
+ TriggerAssetWatcherReferenceResponse,
+ TriggerCallbackReferenceResponse,
+ TriggerCollectionResponse,
+ TriggerDetailResponse,
+ TriggerTaskInstanceReferenceResponse,
+)
+from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
+from airflow.api_fastapi.core_api.security import ReadableTriggersFilterDep
+from airflow.models.asset import AssetWatcherModel
+from airflow.models.dagrun import DagRun
+from airflow.models.deadline import Deadline
+from airflow.models.dynamic_region import SENTINEL_REGION_ID
+from airflow.models.task_coordinates import public_map_index_expression
+from airflow.models.taskinstance import TaskInstance
+from airflow.models.trigger import Trigger, TriggerKind
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+ from uuid import UUID
+
+ from sqlalchemy.orm import Session
+ from sqlalchemy.sql import ColumnElement, Select
+
+triggers_router = AirflowRouter(tags=["Trigger"], prefix="/triggers")
+
+
+def _sort_null_as_smallest(column: ColumnElement) -> list[ColumnElement]:
+ # Postgres sorts NULL as the largest value, MySQL and SQLite as the
smallest; without the leading key a
+ # descending sort would list the triggers that never failed (or never
fired) first on Postgres only.
+ return [column.is_not(None).label(f"has_{column.key}"), column]
+
+
+def _triggers_select() -> Select:
+ # Only triggers their owner still refers to: the rest are deleted on the
triggerer's next cleanup. The
+ # kwargs are never returned, so they are not even loaded.
+ return (
+ select(Trigger)
+ .outerjoin(Trigger.triggerer_job)
+ .where(Trigger.owner_still_refers_expression())
+ .options(
+ defer(Trigger.encrypted_kwargs),
+ contains_eager(Trigger.triggerer_job),
+
selectinload(Trigger.asset_watchers).joinedload(AssetWatcherModel.asset),
+ selectinload(Trigger.callback),
+ )
+ )
+
+
+def _get_callback_dag_run_ids(triggers: Iterable[Trigger], *, session:
Session) -> dict[UUID, str]:
+ """Return the run id of each callback's Dag run; a callback names its run
only through its deadline."""
+ callback_ids = [trigger.callback.id for trigger in triggers if
trigger.callback is not None]
+ if not callback_ids:
+ return {}
+ rows = session.execute(
+ select(Deadline.callback_id, DagRun.run_id)
+ .join(Deadline.dagrun)
+ .where(Deadline.callback_id.in_(callback_ids))
+ )
+ return {callback_id: run_id for callback_id, run_id in rows}
+
+
+def _get_public_map_indexes(triggers: Iterable[Trigger], *, session: Session)
-> dict[UUID, int]:
+ """Return the public map index of each trigger's task instance; one query
for those in a dynamic region."""
+ task_instances = [trigger.task_instance for trigger in triggers if
trigger.task_instance is not None]
+ map_indexes = {ti.id: ti.region_index for ti in task_instances if
ti.region_id == SENTINEL_REGION_ID}
+ if regional := [ti.id for ti in task_instances if ti.id not in
map_indexes]:
+ rows = session.execute(
+ select(TaskInstance.id, public_map_index_expression(TaskInstance))
+ .where(TaskInstance.id.in_(regional))
+ .execution_options(include_all_attempts=True)
+ )
+ map_indexes.update({ti_id: map_index for ti_id, map_index in rows})
+ return map_indexes
+
+
+def _build_response(
+ trigger: Trigger,
+ *,
+ readable_asset_ids: set[int],
+ callback_dag_run_ids: dict[UUID, str],
+ public_map_indexes: dict[UUID, int],
+) -> TriggerDetailResponse | None:
+ """
+ Describe a trigger and its owner, the same way for every kind; ``None`` if
the owner just let go.
+
+ A trigger can serve watchers on several assets; only those on assets the
caller can read are listed.
+ """
+ if (kind := trigger.kind) is None:
+ return None
+ ti = trigger.task_instance if kind == TriggerKind.TASK else None
+ callback = trigger.callback if kind == TriggerKind.CALLBACK else None
+ job = trigger.triggerer_job
+ return TriggerDetailResponse(
+ id=trigger.id,
+ kind=kind,
+ classpath=trigger.classpath,
+ created_date=trigger.created_date,
+ queue=trigger.queue,
+ team_name=trigger.team_name,
+ status=trigger.status(),
+ triggerer_hostname=job.hostname if job is not None else None,
+ consecutive_failures=trigger.consecutive_failures or 0,
+ first_failed_at=trigger.first_failed_at,
+ last_failed_at=trigger.last_failed_at,
+ last_error=trigger.last_error,
+ last_event_at=trigger.last_event_at,
+ dag_id=trigger.dag_id,
+ task_instance=None
+ if ti is None
+ else TriggerTaskInstanceReferenceResponse(
+ dag_id=ti.dag_id,
+ dag_run_id=ti.run_id,
+ task_id=ti.task_id,
+ map_index=public_map_indexes[ti.id],
Review Comment:
nit: `public_map_indexes[ti.id]` raises `KeyError`, and so a 500, if the
task instance is deleted between the two queries. `.get()` with a fallback
avoids that.
##########
airflow-core/src/airflow/api_fastapi/core_api/routes/public/triggers.py:
##########
@@ -0,0 +1,275 @@
+# 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.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated
+
+from fastapi import Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.orm import contains_eager, defer, selectinload
+
+from airflow.api_fastapi.common.db.common import SessionDep, paginated_select
+from airflow.api_fastapi.common.parameters.base import QueryLimit, QueryOffset
+from airflow.api_fastapi.common.parameters.sort import SortParam
+from airflow.api_fastapi.common.parameters.trigger import (
+ QueryTriggerAssetIdFilter,
+ QueryTriggerDagIdFilter,
+ QueryTriggererHostnameFilter,
+ QueryTriggerKindFilter,
+ QueryTriggerStatusFilter,
+ QueryTriggerTeamsFilter,
+)
+from airflow.api_fastapi.common.router import AirflowRouter
+from airflow.api_fastapi.core_api.datamodels.trigger import (
+ TriggerAssetWatcherReferenceResponse,
+ TriggerCallbackReferenceResponse,
+ TriggerCollectionResponse,
+ TriggerDetailResponse,
+ TriggerTaskInstanceReferenceResponse,
+)
+from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
+from airflow.api_fastapi.core_api.security import ReadableTriggersFilterDep
+from airflow.models.asset import AssetWatcherModel
+from airflow.models.dagrun import DagRun
+from airflow.models.deadline import Deadline
+from airflow.models.dynamic_region import SENTINEL_REGION_ID
+from airflow.models.task_coordinates import public_map_index_expression
+from airflow.models.taskinstance import TaskInstance
+from airflow.models.trigger import Trigger, TriggerKind
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+ from uuid import UUID
+
+ from sqlalchemy.orm import Session
+ from sqlalchemy.sql import ColumnElement, Select
+
+triggers_router = AirflowRouter(tags=["Trigger"], prefix="/triggers")
+
+
+def _sort_null_as_smallest(column: ColumnElement) -> list[ColumnElement]:
+ # Postgres sorts NULL as the largest value, MySQL and SQLite as the
smallest; without the leading key a
+ # descending sort would list the triggers that never failed (or never
fired) first on Postgres only.
+ return [column.is_not(None).label(f"has_{column.key}"), column]
+
+
+def _triggers_select() -> Select:
Review Comment:
nit: AGENTS.md asks for verb names for functions:
- `_triggers_select` -> `_build_triggers_select`
- `_triggers_watching` -> `_select_triggers_watching`
- `triggerer_alive_clauses` -> `get_triggerer_alive_clauses`
##########
airflow-core/docs/administration-and-deployment/logging-monitoring/triggers.rst:
##########
@@ -0,0 +1,115 @@
+.. 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.
+
+Monitoring triggers
+===================
+
+The triggerer runs three kinds of trigger: the trigger of a :doc:`deferred
task </authoring-and-scheduling/deferring>`,
+a triggerer callback (for example a deadline alert's ``AsyncCallback``), and an
+:doc:`asset watcher </authoring-and-scheduling/event-scheduling>`. ``GET
/api/v2/triggers`` lists all of them the same
+way: what each one runs for, which triggerer runs it, and how it is doing.
Trigger arguments are never returned.
+
+Status
+------
+
+``pending``
+ Not picked up by a triggerer yet.
+
+``running``
+ On a live triggerer and not failed since it last recovered. For an asset
watcher this does not mean the
+ source it watches is healthy, only that the trigger is not raising;
``last_event_at`` says when it last fired.
+
+``failing``
+ On a live triggerer, and it raised or ended without yielding an event
since it last recovered.
+ ``consecutive_failures``, ``first_failed_at`` and ``last_error`` say how
often, since when and why. A trigger
+ that is still wanted, such as an asset watcher, is re-created within
seconds and stays ``failing`` until it
+ recovers: it yields an event, or stays up for two minutes.
+
+``orphaned``
+ Its triggerer stopped sending heartbeats. Another triggerer takes it over
within
+ ``[triggerer] triggerer_health_check_threshold`` seconds.
+
+``triggerer_hostname`` is the host of the triggerer the trigger is assigned
to; each live triggerer's heartbeat is
+on the health endpoint. The failure count is kept on the trigger, so it
carries over when the triggerer restarts
+or another triggerer takes the trigger over. ``last_error`` is kept after a
recovery.
+
+A trigger is listed only while its owner still refers to it. Once a deferred
task resumes or fails, a callback
+finishes, or a watcher is removed, the trigger is deleted on the triggerer's
next cleanup, and its outcome stays
+with its owner: the task instance's state and logs, the callback's state and
logs.
+
+During a rolling upgrade from a version that does not record trigger owners, a
deferred task's or callback's
+trigger created by the older components is listed only once a triggerer of
this version picks it up and records its
+owner, and a trigger still run by an older triggerer is never ``failing``,
since older triggerers do not record
+failures.
+
+Owner
+-----
+
+Each trigger names what it runs for: ``task_instance`` (Dag, run, task, map
index, try and state), ``callback``
+(its id, Dag, run and state), or ``asset_watchers`` (each asset and watcher
name; several assets that declare the
+same trigger share one). ``kind`` is worked out from the owner, and ``dag_id``
is the Dag of the task instance, or
+of the Dag run whose deadline the callback belongs to. In the other direction,
a task instance's ``trigger`` and an
+asset's ``watchers`` (with ``status`` and ``trigger_id``) link to the trigger.
+
+Filtering
+---------
+
+``kind`` (``task``, ``callback`` or ``asset_watcher``), ``status``,
``triggerer_hostname``, ``dag_id``,
+``asset_id`` and ``teams`` can be combined; ``kind``, ``status`` and ``teams``
take several values. For example,
+to find the triggers that need attention:
+
+.. code-block:: bash
+
+ curl -H "Authorization: Bearer $TOKEN" \
+ "$AIRFLOW_URL/api/v2/triggers?status=failing&status=orphaned"
+
+and an asset's watchers, with their status, come with the asset itself:
+
+.. code-block:: bash
+
+ curl -H "Authorization: Bearer $TOKEN" "$AIRFLOW_URL/api/v2/assets/12"
+
+``order_by`` takes ``id`` (the default), ``created_date``,
``consecutive_failures``, ``last_failed_at`` and
+``last_event_at``, ``-`` first for descending. A trigger that never failed or
never fired sorts first ascending and
+last descending, on every database.
+
+``GET /api/v2/triggers/{trigger_id}`` returns one trigger.
+
+Who can see a trigger
+---------------------
+
+A trigger is listed for whoever can see its owner: read access to the Dag of
its task instance or callback, or
+read access to an asset one of its watchers watches. There is no permission of
its own, so the generated
+permission tables show the routes as needing none. With ``[core] multi_team``
on, team access comes with the
+Dag; assets have no team, so an asset's watchers are listed for whoever can
read the asset. A trigger shared
Review Comment:
This only checks Dag read. A task trigger returns task instance data (state,
try number) and last_error, which elsewhere need Task Instance read: the TI
list route uses requires_access_dag("GET", TASK_INSTANCE) (task_instances.py
L559). Callback data is gated on RUN in routes/ui/deadlines.py L81. So a role
with Dag read but no Task Instance read can now see it through /triggers. Could
you gate task triggers on TASK_INSTANCE and callback triggers on RUN, like
those routes do? That would also stop the permission tables listing these
routes as "Public" (api_permissions_ref.rst L594/L598, FAB access-control.rst
L767/L771).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]