Repository: PrefectHQ/prefect

Issue:
`prefect deploy` should accept an argument to the path of an alternate deployment file
### 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

Hard-coded references to `prefect.yaml` exist in `src/cli/deploy.py`; the deployment file must be named `prefect.yaml` in order to be recognized by the `prefect deploy` command.

### Describe the proposed behavior

Using a new `file_path` arg passed to `_load_deploy_configs_and_actions`, the user would be able to provide a command line flag (`--deploy-file` perhaps?) to `prefect deploy`.

### Example Use

```console
prefect deploy --deploy-file=./prefect-local.yaml
```

### Additional context

_No response_


Discussion:
Thanks @systembell, this could be useful and I'm putting it on our backlog. We would also welcome a PR for this functionality.
Working on it, just needs tests. I will try to get it in this week. Just for some context, my intent is to use this to shorten the gap between local development where all of the devs on my team are using Process infra with Local storage and in non-locals it's k8s. I am reluctant to have something like a `deployment-name-local` in `prefect.yaml` proper so this feels like the best approach. Could certainly be useful.
This is more than an enhancement, as it surfaces as a bug if you don't know that `prefect.yaml` needs to be at the project root.

The `entrypoint: path/to/flow.py::func_name` must be resolved relative to the location of the `prefect.yaml` file. 

I've attempted a few times to migrate from a stack of `deploy-my.flow1.sh` scripts consisting of `prefect deployment build ENTRYPOINT ..etc` over to the `prefect.yaml` configuration management (which is a better idea 😄), and I always get tripped up by relative paths.

Here's the problem I see:
* All deployments have to go in a single `prefect.yaml` file (for me it will be dozens, hundreds eventually)
* Try to organize the `prefect.yaml` configurations into subfolders and then you need to adjust entry path
    * `entrypoint: src/proj1/myflow.py:myfunc` --> `entrypoint: ../../../src/proj1/myflow.py:myfunc`
* This creates confusion around setting working directories for work-pools and storage blocks

If Prefect is going to be super-opinionated about file organization can we please document this?  I would be able to cut a PR for such documentation, except I really haven't managed to understand it after a lot of effort.
> Working on it, just needs tests. I will try to get it in this week. Just for some context, my intent is to use this to shorten the gap between local development where all of the devs on my team are using Process infra with Local storage and in non-locals it's k8s. I am reluctant to have something like a `deployment-name-local` in `prefect.yaml` proper so this feels like the best approach. Could certainly be useful.

Big ➕ 💯  right here, I'm always testing new concepts with local process and storage, my flow code invariably is never found because I'm not setting working directory or storage block parameters correctly. 

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
@@ -14,6 +14,7 @@
 import yaml
 from rich.console import Console
 from rich.panel import Panel
+from yaml.error import YAMLError
 
 from prefect._internal.compatibility.deprecated import (
     generate_deprecation_message,
@@ -211,6 +212,11 @@ async def deploy(
             " provided, this flag will be ignored."
         ),
     ),
+    prefect_file: Path = typer.Option(
+        Path("prefect.yaml"),
+        "--prefect-file",
+        help="Specify a custom path to a prefect.yaml file",
+    ),
     ci: bool = typer.Option(
         False,
         "--ci",
@@ -260,8 +266,9 @@ async def deploy(
         "enforce_parameter_schema": enforce_parameter_schema,
     }
     try:
-        deploy_configs, actions = _load_deploy_configs_and_actions(ci=ci)
-
+        deploy_configs, actions = _load_deploy_configs_and_actions(
+            prefect_file=prefect_file, ci=ci
+        )
         parsed_names = []
         for name in names:
             if "*" in name:
@@ -287,6 +294,7 @@ async def deploy(
                 actions=actions,
                 deploy_all=deploy_all,
                 ci=ci,
+                prefect_file=prefect_file,
             )
         else:
             # Accommodate passing in -n flow-name/deployment-name as well as -n deployment-name
@@ -299,6 +307,7 @@ async def deploy(
                 actions=actions,
                 options=options,
                 ci=ci,
+                prefect_file=prefect_file,
             )
     except ValueError as exc:
         exit_with_error(str(exc))
@@ -311,6 +320,7 @@ async def _run_single_deploy(
     options: Optional[Dict] = None,
     ci: bool = False,
     client: PrefectClient = None,
+    prefect_file: Path = Path("prefect.yaml"),
 ):
     deploy_config = deepcopy(deploy_config) if deploy_config else {}
     actions = deepcopy(actions) if actions else {}
@@ -359,8 +369,7 @@ async def _run_single_deploy(
             raise ValueError(
                 "An entrypoint must be provided:\n\n"
                 " \t[yellow]prefect deploy path/to/file.py:flow_function\n\n"
-                "You can also provide an entrypoint in a prefect.yaml"
-                " file located in the current working directory."
+                "You can also provide an entrypoint in a prefect.yaml file."
             )
         deploy_config["entrypoint"] = await prompt_entrypoint(app.console)
     if deploy_config.get("flow_name") and deploy_config.get("entrypoint"):
@@ -670,7 +679,9 @@ async def _run_single_deploy(
         )
 
     identical_deployment_exists_in_prefect_file = (
-        _check_if_identical_deployment_in_prefect_file(deploy_config_before_templating)
+        _check_if_identical_deployment_in_prefect_file(
+            deploy_config_before_templating, prefect_file
+        )
     )
     if should_prompt_for_save and not identical_deployment_exists_in_prefect_file:
         if confirm(
@@ -682,7 +693,8 @@ async def _run_single_deploy(
         ):
             matching_deployment_exists = (
                 _check_for_matching_deployment_name_and_entrypoint_in_prefect_file(
-                    deploy_config=deploy_config_before_templating
+                    deploy_config=deploy_config_before_templating,
+                    prefect_file=prefect_file,
                 )
             )
             if matching_deployment_exists and not confirm(
@@ -691,14 +703,14 @@ async def _run_single_deploy(
                     f" [yellow]{deploy_config_before_templating.get('name')}[/yellow]"
                     " and entrypoint:"
                     f" [yellow]{deploy_config_before_templating.get('entrypoint')}[/yellow]"
-                    " in the [yellow]prefect.yaml[/yellow] file. Would you like to"
-                    " overwrite that entry?"
+                    f" in the [yellow]prefect.yaml[/yellow] file at {prefect_file}."
+                    " Would you like to overwrite that entry?"
                 ),
             ):
                 app.console.print(
                     "[red]Cancelled saving deployment configuration"
                     f" '{deploy_config_before_templating.get('name')}' to the"
-                    " prefect.yaml file.[/red]"
+                    f" deployment configuration file[/red] at {prefect_file}"
                 )
             else:
                 _save_deployment_to_prefect_file(
@@ -707,15 +719,16 @@ async def _run_single_deploy(
                     push_steps=push_steps or None,
                     pull_steps=pull_steps or None,
                     triggers=trigger_specs or None,
+                    prefect_file=prefect_file,
                 )
                 app.console.print(
                     (
-                        "\n[green]Deployment configuration saved to prefect.yaml![/]"
+                        f"\n[green]Deployment configuration saved to {prefect_file}![/]"
                         " You can now deploy using this deployment configuration"
                         " with:\n\n\t[blue]$ prefect deploy -n"
                         f" {deploy_config['name']}[/]\n\nYou can also make changes to"
                         " this deployment configuration by making changes to the"
-                        " prefect.yaml file."
+                        " YAML file."
                     ),
                 )
     if not work_pool.is_push_pool and not work_pool.is_managed_pool:
@@ -746,6 +759,7 @@ async def _run_multi_deploy(
     names: Optional[List[str]] = None,
     deploy_all: bool = False,
     ci: bool = False,
+    prefect_file: Path = Path("prefect.yaml"),
 ):
     deploy_configs = deepcopy(deploy_configs) if deploy_configs else []
     actions = deepcopy(actions) if actions else {}
@@ -776,7 +790,9 @@ async def _run_multi_deploy(
                 app.console.print("Skipping unnamed deployment.", style="yellow")
                 continue
         app.console.print(Panel(f"Deploying {deploy_config['name']}", style="blue"))
-        await _run_single_deploy(deploy_config, actions, ci=ci)
+        await _run_single_deploy(
+            deploy_config, actions, ci=ci, prefect_file=prefect_file
+        )
 
 
 def _construct_schedule(
@@ -1157,9 +1173,11 @@ def _handle_deployment_yaml_copy(prefect_yaml_contents, ci):
         )
 
 
-def _load_deploy_configs_and_actions(ci=False) -> Tuple[List[Dict], Dict]:
+def _load_deploy_configs_and_actions(
+    prefect_file: Path, ci: bool = False
+) -> Tuple[List[Dict], Dict]:
     """
-    Load deploy configs and actions from the prefect.yaml file.
+    Load deploy configs and actions from a deployment configuration YAML file.
 
     Handles the deprecation of the deployment.yaml file.
 
@@ -1170,9 +1188,19 @@ def _load_deploy_configs_and_actions(ci=False) -> Tuple[List[Dict], Dict]:
         Tuple[List[Dict], Dict]: a tuple of deployment configurations and actions
     """
     try:
-        with open("prefect.yaml", "r") as f:
+        with prefect_file.open("r") as f:
             prefect_yaml_contents = yaml.safe_load(f)
-    except FileNotFoundError:
+    except (FileNotFoundError, IsADirectoryError, YAMLError) as exc:
+        app.console.print(
+            f"Unable to read the specified config file. Reason: {exc}. Skipping.",
+            style="yellow",
+        )
+        prefect_yaml_contents = {}
+    if not isinstance(prefect_yaml_contents, dict):
+        app.console.print(
+            "Unable to parse the specified config file. Skipping.",
+            style="yellow",
+        )
         prefect_yaml_contents = {}
 
     actions = {
@@ -1518,9 +1546,8 @@ def _apply_cli_options_to_deploy_config(deploy_config, cli_options):
 
 
 def _check_for_matching_deployment_name_and_entrypoint_in_prefect_file(
-    deploy_config,
+    deploy_config, prefect_file: Path = Path("prefect.yaml")
 ) -> bool:
-    prefect_file = Path("prefect.yaml")
     if prefect_file.exists():
         with prefect_file.open(mode="r") as f:
             parsed_prefect_file_contents = yaml.safe_load(f)
@@ -1538,7 +1565,7 @@ def _check_for_matching_deployment_name_and_entrypoint_in_prefect_file(
 
 
 def _check_if_identical_deployment_in_prefect_file(
-    untemplated_deploy_config: Dict,
+    untemplated_deploy_config: Dict, prefect_file: Path = Path("prefect.yaml")
 ) -> bool:
     """
     Check if the given deploy config is identical to an existing deploy config in the
@@ -1551,8 +1578,6 @@ def _check_if_identical_deployment_in_prefect_file(
     user_specified_deploy_config = _format_deployment_for_saving_to_prefect_file(
         untemplated_deploy_config
     )
-
-    prefect_file = Path("prefect.yaml")
     if prefect_file.exists():
         with prefect_file.open(mode="r") as f:
             parsed_prefect_file_contents = yaml.safe_load(f)
diff --git a/src/prefect/deployments/base.py b/src/prefect/deployments/base.py
--- a/src/prefect/deployments/base.py
+++ b/src/prefect/deployments/base.py
@@ -425,6 +425,7 @@ def _save_deployment_to_prefect_file(
     push_steps: Optional[List[Dict]] = None,
     pull_steps: Optional[List[Dict]] = None,
     triggers: Optional[List[Dict]] = None,
+    prefect_file: Path = Path("prefect.yaml"),
 ):
     """
     Save a deployment configuration to the `prefect.yaml` file in the
@@ -438,7 +439,6 @@ def _save_deployment_to_prefect_file(
     deployment = _format_deployment_for_saving_to_prefect_file(deployment)
 
     current_directory_name = os.path.basename(os.getcwd())
-    prefect_file = Path("prefect.yaml")
     if not prefect_file.exists():
         create_default_prefect_yaml(
             ".",


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,11 +1,14 @@
 import datetime
+import io
 import json
 import os
 import shutil
 import subprocess
 import sys
+import tempfile
 from datetime import timedelta
 from pathlib import Path
+from typing import Optional
 from unittest import mock
 from uuid import UUID, uuid4
 
@@ -23,6 +26,7 @@
     _initialize_deployment_triggers,
 )
 from prefect.client.orchestration import PrefectClient, ServerType
+from prefect.client.schemas.objects import WorkPool
 from prefect.deployments import register_flow
 from prefect.deployments.base import (
     _save_deployment_to_prefect_file,
@@ -163,7 +167,7 @@ async def default_agent_pool(prefect_client):
 
 
 @pytest.fixture
-async def docker_work_pool(prefect_client: PrefectClient):
+async def docker_work_pool(prefect_client: PrefectClient) -> WorkPool:
     return await prefect_client.create_work_pool(
         work_pool=WorkPoolCreate(
             name="test-docker-work-pool",
@@ -5131,7 +5135,8 @@ async def test_deploy_resolves_variables_in_deployments_section(
                     "Would you like to save configuration for this deployment for"
                     " faster deployments in the future?"
                 ),
-                "Would you like to overwrite",
+                "Would you like",
+                "to overwrite that entry?",
                 "Deployment configuration saved to prefect.yaml!",
             ],
         )
@@ -6985,3 +6990,121 @@ async def test_prompt_push_docker_image_accepted_private_registry_reject_use_exi
 
         with pytest.raises(ImportError):
             import prefect_docker  # noqa
+
+
+class TestDeployingUsingCustomPrefectFile:
+    def customize_from_existing_prefect_file(
+        self,
+        existing_file: Path,
+        new_file: io.TextIOBase,
+        work_pool: Optional[WorkPool],
+    ):
+        with existing_file.open(mode="r") as f:
+            contents = yaml.safe_load(f)
+
+        # Customize the template
+        contents["deployments"] = [
+            {
+                "name": "test-deployment1",
+                "entrypoint": "flows/hello.py:my_flow",
+                "work_pool": {"name": work_pool.name if work_pool else "some_name"},
+            },
+            {
+                "name": "test-deployment2",
+                "entrypoint": "flows/hello.py:my_flow",
+                "work_pool": {"name": work_pool.name if work_pool else "some_name"},
+            },
+        ]
+        # Write the customized template
+        yaml.dump(contents, new_file)
+
+    @pytest.mark.usefixtures("project_dir")
+    async def test_deploying_using_custom_prefect_file(
+        self, prefect_client: PrefectClient, work_pool: WorkPool
+    ):
+        # Create and use a temporary prefect.yaml file
+        with tempfile.NamedTemporaryFile("w+") as fp:
+            self.customize_from_existing_prefect_file(
+                Path("prefect.yaml"), fp, work_pool
+            )
+
+            await run_sync_in_worker_thread(
+                invoke_and_assert,
+                command=f"deploy --all --prefect-file {fp.name}",
+                expected_code=0,
+                user_input=(
+                    # decline remote storage
+                    "n"
+                    + readchar.key.ENTER
+                    # reject saving configuration
+                    + "n"
+                    + readchar.key.ENTER
+                    # reject naming deployment
+                    + "n"
+                    + readchar.key.ENTER
+                ),
+                expected_output_contains=[
+                    (
+                        "Deployment 'An important name/test-deployment1' successfully"
+                        " created"
+                    ),
+                    (
+                        "Deployment 'An important name/test-deployment2' successfully"
+                        " created"
+                    ),
+                ],
+            )
+
+        # Check if deployments were created correctly
+        deployment1 = await prefect_client.read_deployment_by_name(
+            "An important name/test-deployment1",
+        )
+        deployment2 = await prefect_client.read_deployment_by_name(
+            "An important name/test-deployment2"
+        )
+
+        assert deployment1.name == "test-deployment1"
+        assert deployment1.work_pool_name == work_pool.name
+        assert deployment2.name == "test-deployment2"
+        assert deployment2.work_pool_name == work_pool.name
+
+    @pytest.mark.usefixtures("project_dir")
+    async def test_deploying_using_missing_prefect_file(self):
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command="deploy --all --prefect-file THIS_FILE_DOES_NOT_EXIST",
+            expected_code=1,
+            expected_output_contains=[
+                "Unable to read the specified config file. Reason: [Errno 2] "
+                "No such file or directory: 'THIS_FILE_DOES_NOT_EXIST'. Skipping"
+            ],
+        )
+
+    @pytest.mark.usefixtures("project_dir")
+    @pytest.mark.parametrize(
+        "content", ["{this isn't valid YAML!}", "unbalanced blackets: ]["]
+    )
+    async def test_deploying_using_malformed_prefect_file(self, content: str):
+        with tempfile.NamedTemporaryFile("w+") as fp:
+            fp.write(content)
+
+            await run_sync_in_worker_thread(
+                invoke_and_assert,
+                command=f"deploy --all --prefect-file {fp.name}",
+                expected_code=1,
+                expected_output_contains=[
+                    "Unable to parse the specified config file. Skipping."
+                ],
+            )
+
+    @pytest.mark.usefixtures("project_dir")
+    async def test_deploying_directory_as_prefect_file(self):
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command="deploy --all --prefect-file ./",
+            expected_code=1,
+            expected_output_contains=[
+                "Unable to read the specified config file. Reason: [Errno 21] "
+                "Is a directory: '.'. Skipping."
+            ],
+        )