Repository: PrefectHQ/prefect

Issue:
prefect.yaml should support deployment triggers
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar request and didn't find it.
- [X] I searched the Prefect documentation for this feature.

### Prefect Version

2.x

### Describe the current behavior

Currently deployment triggers are only supported on the old-style deployment specifications.

### Describe the proposed behavior

Deployment triggers should be supported in the new-style specifications.

### Example Use

_No response_

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/src/prefect/cli/deploy.py b/src/prefect/cli/deploy.py
--- a/src/prefect/cli/deploy.py
+++ b/src/prefect/cli/deploy.py
@@ -4,7 +4,8 @@
 from copy import deepcopy
 from datetime import timedelta
 from pathlib import Path
-from typing import Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Tuple
+from uuid import UUID
 
 import typer
 import typer.core
@@ -31,6 +32,7 @@
     RRuleSchedule,
 )
 from prefect.client.utilities import inject_client
+from prefect.events.schemas import DeploymentTrigger
 from prefect.exceptions import ObjectNotFound
 from prefect.flows import load_flow_from_entrypoint
 from prefect.deployments import find_prefect_directory, register_flow
@@ -52,7 +54,7 @@
 
 from prefect.utilities.slugify import slugify
 
-from prefect.client.orchestration import PrefectClient
+from prefect.client.orchestration import PrefectClient, ServerType
 
 from prefect._internal.compatibility.deprecated import (
     generate_deprecation_message,
@@ -371,7 +373,8 @@ async def _run_single_deploy(
         # set entrypoint from prior registration
         deploy_config["entrypoint"] = flows[deploy_config["flow_name"]]
 
-    if not deploy_config.get("name"):
+    deployment_name = deploy_config.get("name")
+    if not deployment_name:
         if not is_interactive() or ci:
             raise ValueError("A deployment name must be provided.")
         deploy_config["name"] = prompt("Deployment name", default="default")
@@ -428,6 +431,22 @@ async def _run_single_deploy(
             console=app.console, client=client
         )
 
+    triggers: List[DeploymentTrigger] = []
+    trigger_specs = deploy_config.get("triggers")
+    if trigger_specs:
+        triggers = _initialize_deployment_triggers(deployment_name, trigger_specs)
+        if client.server_type != ServerType.CLOUD:
+            app.console.print(
+                Panel(
+                    (
+                        "Deployment triggers are only supported on "
+                        "Prefect Cloud. Any triggers defined for the deployment will "
+                        "not be created."
+                    ),
+                ),
+                style="yellow",
+            )
+
     ## RUN BUILD AND PUSH STEPS
     step_outputs = {}
     if build_steps:
@@ -482,6 +501,8 @@ async def _run_single_deploy(
         infra_overrides=get_from_dict(deploy_config, "work_pool.job_variables"),
     )
 
+    await _create_deployment_triggers(client, deployment_id, triggers)
+
     app.console.print(
         Panel(
             f"Deployment '{deploy_config['flow_name']}/{deploy_config['name']}'"
@@ -1087,3 +1108,32 @@ def _check_for_matching_deployment_name_and_entrypoint_in_prefect_file(
                     ):
                         return True
     return False
+
+
+def _initialize_deployment_triggers(
+    deployment_name: str, triggers_spec: List[Dict[str, Any]]
+) -> List[DeploymentTrigger]:
+    triggers = []
+    for i, spec in enumerate(triggers_spec, start=1):
+        spec.setdefault("name", f"{deployment_name}__automation_{i}")
+        triggers.append(DeploymentTrigger(**spec))
+
+    return triggers
+
+
+async def _create_deployment_triggers(
+    client: PrefectClient, deployment_id: UUID, triggers: List[DeploymentTrigger]
+):
+    if client.server_type == ServerType.CLOUD:
+        # The triggers defined in the deployment spec are, essentially,
+        # anonymous and attempting truly sync them with cloud is not
+        # feasible. Instead, we remove all automations that are owned
+        # by the deployment, meaning that they were created via this
+        # mechanism below, and then recreate them.
+        await client.delete_resource_owned_automations(
+            f"prefect.deployment.{deployment_id}"
+        )
+
+        for trigger in triggers:
+            trigger.set_deployment_id(deployment_id)
+            await client.create_automation(trigger.as_automation())


Test patch:
diff --git a/tests/cli/test_deploy.py b/tests/cli/test_deploy.py
--- a/tests/cli/test_deploy.py
+++ b/tests/cli/test_deploy.py
@@ -1,9 +1,12 @@
+import datetime
 import os
 import shutil
 import subprocess
 import sys
 from datetime import timedelta
 from pathlib import Path
+from unittest import mock
+from uuid import UUID, uuid4
 
 import pendulum
 from prefect.cli.deploy import (
@@ -15,8 +18,13 @@
 import yaml
 
 import prefect
+from prefect.cli.deploy import (
+    _initialize_deployment_triggers,
+    _create_deployment_triggers,
+)
 from prefect.blocks.system import Secret
-from prefect.client.orchestration import PrefectClient
+from prefect.client.orchestration import PrefectClient, ServerType
+from prefect.events.schemas import Posture
 from prefect.exceptions import ObjectNotFound
 from prefect.deployments import register_flow
 from prefect.deployments.base import (
@@ -27,6 +35,7 @@
 from prefect.server.schemas.actions import WorkPoolCreate
 from prefect.server.schemas.schedules import CronSchedule
 from prefect.testing.cli import invoke_and_assert
+from prefect.testing.utilities import AsyncMock
 from prefect.utilities.asyncutils import run_sync_in_worker_thread
 
 
@@ -3338,3 +3347,193 @@ async def test_no_matching_deployment_in_prefect_file_returns_false(self):
             )
         )
         assert not matching_deployment_exists_2
+
+
+class TestDeploymentTriggerSyncing:
+    async def test_initialize_named_deployment_triggers(self):
+        trigger_spec = {
+            "name": "Trigger McTriggerson",
+            "enabled": True,
+            "match": {"prefect.resource.id": "prefect.flow-run.*"},
+            "expect": ["prefect.flow-run.Completed"],
+            "match_related": {
+                "prefect.resource.name": "seed",
+                "prefect.resource.role": "flow",
+            },
+        }
+
+        triggers = _initialize_deployment_triggers("my_deployment", [trigger_spec])
+        assert triggers == [
+            {
+                "name": "Trigger McTriggerson",
+                "description": "",
+                "enabled": True,
+                "match": {"prefect.resource.id": "prefect.flow-run.*"},
+                "match_related": {
+                    "prefect.resource.name": "seed",
+                    "prefect.resource.role": "flow",
+                },
+                "after": set(),
+                "expect": {"prefect.flow-run.Completed"},
+                "for_each": set(),
+                "posture": Posture.Reactive,
+                "threshold": 1,
+                "within": datetime.timedelta(0),
+                "parameters": None,
+            }
+        ]
+
+    async def test_initialize_deployment_triggers_implicit_name(self):
+        trigger_spec = {
+            "enabled": True,
+            "match": {"prefect.resource.id": "prefect.flow-run.*"},
+            "expect": ["prefect.flow-run.Completed"],
+            "match_related": {
+                "prefect.resource.name": "seed",
+                "prefect.resource.role": "flow",
+            },
+        }
+
+        triggers = _initialize_deployment_triggers("my_deployment", [trigger_spec])
+        assert triggers[0].name == "my_deployment__automation_1"
+
+    async def test_create_deployment_triggers(self):
+        client = AsyncMock()
+        client.server_type = ServerType.CLOUD
+
+        trigger_spec = {
+            "enabled": True,
+            "match": {"prefect.resource.id": "prefect.flow-run.*"},
+            "expect": ["prefect.flow-run.Completed"],
+            "match_related": {
+                "prefect.resource.name": "seed",
+                "prefect.resource.role": "flow",
+            },
+        }
+
+        triggers = _initialize_deployment_triggers("my_deployment", [trigger_spec])
+        deployment_id = uuid4()
+
+        await _create_deployment_triggers(client, deployment_id, triggers)
+
+        assert triggers[0]._deployment_id == deployment_id
+        client.delete_resource_owned_automations.assert_called_once_with(
+            f"prefect.deployment.{deployment_id}"
+        )
+        client.create_automation.assert_called_once_with(triggers[0].as_automation())
+
+    async def test_create_deployment_triggers_not_cloud_noop(self):
+        client = AsyncMock()
+        client.server_type = ServerType.SERVER
+
+        trigger_spec = {
+            "enabled": True,
+            "match": {"prefect.resource.id": "prefect.flow-run.*"},
+            "expect": ["prefect.flow-run.Completed"],
+            "match_related": {
+                "prefect.resource.name": "seed",
+                "prefect.resource.role": "flow",
+            },
+        }
+
+        triggers = _initialize_deployment_triggers("my_deployment", [trigger_spec])
+        deployment_id = uuid4()
+
+        await _create_deployment_triggers(client, deployment_id, triggers)
+
+        client.delete_resource_owned_automations.assert_not_called()
+        client.create_automation.assert_not_called()
+
+    async def test_triggers_creation_orchestrated(
+        self, project_dir, prefect_client, work_pool
+    ):
+        prefect_file = Path("prefect.yaml")
+        with prefect_file.open(mode="r") as f:
+            contents = yaml.safe_load(f)
+
+        contents["deployments"] = [
+            {
+                "name": "test-name-1",
+                "work_pool": {
+                    "name": work_pool.name,
+                },
+                "triggers": [
+                    {
+                        "enabled": True,
+                        "match": {"prefect.resource.id": "prefect.flow-run.*"},
+                        "expect": ["prefect.flow-run.Completed"],
+                        "match_related": {
+                            "prefect.resource.name": "seed",
+                            "prefect.resource.role": "flow",
+                        },
+                    }
+                ],
+            }
+        ]
+
+        expected_triggers = _initialize_deployment_triggers(
+            "test-name-1", contents["deployments"][0]["triggers"]
+        )
+
+        with prefect_file.open(mode="w") as f:
+            yaml.safe_dump(contents, f)
+
+        with mock.patch(
+            "prefect.cli.deploy._create_deployment_triggers",
+            AsyncMock(),
+        ) as create_triggers:
+            await run_sync_in_worker_thread(
+                invoke_and_assert,
+                command="deploy ./flows/hello.py:my_flow -n test-name-1",
+                expected_code=0,
+            )
+
+            assert create_triggers.call_count == 1
+
+            client, deployment_id, triggers = create_triggers.call_args[0]
+            assert isinstance(client, PrefectClient)
+            assert isinstance(deployment_id, UUID)
+
+            expected_triggers[0].set_deployment_id(deployment_id)
+
+            assert triggers == expected_triggers
+
+    async def test_deploy_command_warns_triggers_not_created_not_cloud(
+        self, project_dir, prefect_client, work_pool
+    ):
+        prefect_file = Path("prefect.yaml")
+        with prefect_file.open(mode="r") as f:
+            contents = yaml.safe_load(f)
+
+        contents["deployments"] = [
+            {
+                "name": "test-name-1",
+                "work_pool": {
+                    "name": work_pool.name,
+                },
+                "triggers": [
+                    {
+                        "enabled": True,
+                        "match": {"prefect.resource.id": "prefect.flow-run.*"},
+                        "expect": ["prefect.flow-run.Completed"],
+                        "match_related": {
+                            "prefect.resource.name": "seed",
+                            "prefect.resource.role": "flow",
+                        },
+                    }
+                ],
+            }
+        ]
+
+        with prefect_file.open(mode="w") as f:
+            yaml.safe_dump(contents, f)
+
+        # Deploy the deployment with a name
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command="deploy ./flows/hello.py:my_flow -n test-name-1",
+            expected_code=0,
+            expected_output_contains=[
+                "Deployment triggers are only supported on Prefect Cloud"
+            ],
+        )