Repository: PrefectHQ/prefect

Issue:
Allow setting port for postgres instance in `prefect server start`
## Use Case

It's likely that there's already a postgres instance running on the default port.
`prefect server start` should include an option for setting the port of its Postgres instance.


## Solution

`prefect server start --postgres-port=5433`

Investigate non-deterministic Python 3.6 scheduled parameter test failure
https://circleci.com/gh/PrefectHQ/prefect/60188?utm_campaign=vcs-integration-link&utm_medium=referral&utm_source=github-checks-link
```
=================================== FAILURES ===================================
_______ TestFlowRunMethod.test_flow_dot_run_passes_scheduled_parameters ________

self = <tests.core.test_flow.TestFlowRunMethod object at 0x7f97b853d8d0>

    def test_flow_dot_run_passes_scheduled_parameters(self):
        a = prefect.schedules.clocks.DatesClock(
            [pendulum.now("UTC").add(seconds=0.1)], parameter_defaults=dict(x=1)
        )
        b = prefect.schedules.clocks.DatesClock(
            [pendulum.now("UTC").add(seconds=0.25)], parameter_defaults=dict(x=2)
        )
    
        x = prefect.Parameter("x", default=None, required=False)
        outputs = []
    
        @prefect.task
        def whats_the_param(x):
            outputs.append(x)
    
        with Flow("test", schedule=prefect.schedules.Schedule(clocks=[a, b])) as f:
            whats_the_param(x)
    
        f.run()
    
>       assert outputs == [1, 2]
E       assert [1] == [1, 2]
E         Right contains one more item: 2
E         Full diff:
E         - [1]
E         + [1, 2]

tests/core/test_flow.py:1620: AssertionError
```


Discussion:

Resolving patch:
diff --git a/src/prefect/cli/server.py b/src/prefect/cli/server.py
--- a/src/prefect/cli/server.py
+++ b/src/prefect/cli/server.py
@@ -1,10 +1,15 @@
 import click
 import os
+from pathlib import Path
+import shutil
 import subprocess
+import tempfile
 import time
 
-from pathlib import Path
+import yaml
+
 from prefect import config
+from prefect.utilities.configuration import set_temporary_config
 
 
 def make_env(fname=None):
@@ -13,7 +18,9 @@ def make_env(fname=None):
     PREFECT_ENV = dict(
         DB_CONNECTION_URL=config.server.database.connection_url.replace(
             "localhost", "postgres"
-        )
+        ),
+        GRAPHQL_HOST_PORT=config.server.graphql.host_port,
+        UI_HOST_PORT=config.server.ui.host_port,
     )
 
     APOLLO_ENV = dict(
@@ -29,15 +36,17 @@ def make_env(fname=None):
         PREFECT_API_HEALTH_URL="http://graphql:{port}/health".format(
             port=config.server.graphql.port
         ),
+        APOLLO_HOST_PORT=config.server.host_port,
     )
 
     POSTGRES_ENV = dict(
+        POSTGRES_HOST_PORT=config.server.database.host_port,
         POSTGRES_USER=config.server.database.username,
         POSTGRES_PASSWORD=config.server.database.password,
         POSTGRES_DB=config.server.database.name,
     )
 
-    HASURA_ENV = dict()
+    HASURA_ENV = dict(HASURA_HOST_PORT=config.server.hasura.host_port)
 
     ENV = os.environ.copy()
     ENV.update(**PREFECT_ENV, **APOLLO_ENV, **POSTGRES_ENV, **HASURA_ENV)
@@ -57,7 +66,7 @@ def make_env(fname=None):
 @click.group(hidden=True)
 def server():
     """
-    Commands for interacting with the Prefect Server
+    Commands for interacting with the Prefect Core server
 
     \b
     Usage:
@@ -81,28 +90,181 @@ def server():
     help="The server image versions to use (for example, '0.10.0' or 'master')",
     # TODO: update this default to use prefect.__version__ logic
     default="latest",
+    hidden=True,
 )
 @click.option(
     "--skip-pull",
     help="Pass this flag to skip pulling new images (if available)",
     is_flag=True,
+    hidden=True,
 )
 @click.option(
     "--no-upgrade",
     "-n",
     help="Pass this flag to avoid running a database upgrade when the database spins up",
     is_flag=True,
+    hidden=True,
+)
+@click.option(
+    "--no-ui",
+    "-u",
+    help="Pass this flag to avoid starting the UI",
+    is_flag=True,
+    hidden=True,
+)
+@click.option(
+    "--postgres-port",
+    help="The port used to serve Postgres",
+    default=config.server.database.host_port,
+    type=str,
+    hidden=True,
+)
+@click.option(
+    "--hasura-port",
+    help="The port used to serve Hasura",
+    default=config.server.hasura.host_port,
+    type=str,
+    hidden=True,
+)
+@click.option(
+    "--graphql-port",
+    help="The port used to serve the GraphQL API",
+    default=config.server.graphql.host_port,
+    type=str,
+    hidden=True,
+)
+@click.option(
+    "--ui-port",
+    help="The port used to serve the UI",
+    default=config.server.ui.host_port,
+    type=str,
+    hidden=True,
+)
+@click.option(
+    "--server-port",
+    help="The port used to serve the Core server",
+    default=config.server.host_port,
+    type=str,
+    hidden=True,
+)
+@click.option(
+    "--no-postgres-port",
+    help="Disable port map of Postgres to host",
+    is_flag=True,
+    hidden=True,
 )
 @click.option(
-    "--no-ui", "-u", help="Pass this flag to avoid starting the UI", is_flag=True,
+    "--no-hasura-port",
+    help="Disable port map of Hasura to host",
+    is_flag=True,
+    hidden=True,
 )
-def start(version, skip_pull, no_upgrade, no_ui):
+@click.option(
+    "--no-graphql-port",
+    help="Disable port map of the GraphqlAPI to host",
+    is_flag=True,
+    hidden=True,
+)
+@click.option(
+    "--no-ui-port", help="Disable port map of the UI to host", is_flag=True, hidden=True
+)
+@click.option(
+    "--no-server-port",
+    help="Disable port map of the Core server to host",
+    is_flag=True,
+    hidden=True,
+)
+def start(
+    version,
+    skip_pull,
+    no_upgrade,
+    no_ui,
+    postgres_port,
+    hasura_port,
+    graphql_port,
+    ui_port,
+    server_port,
+    no_postgres_port,
+    no_hasura_port,
+    no_graphql_port,
+    no_ui_port,
+    no_server_port,
+):
     """
-    This command spins up all infrastructure and services for Prefect Server
+    This command spins up all infrastructure and services for the Prefect Core server
+
+    \b
+    Options:
+        --version, -v   TEXT    The server image versions to use (for example, '0.10.0' or 'master')
+                                Defaults to 'latest'
+        --skip-pull             Flag to skip pulling new images (if available)
+        --no-upgrade, -n        Flag to avoid running a database upgrade when the database spins up
+        --no-ui, -u             Flag to avoid starting the UI
+
+    \b
+        --postgres-port TEXT    Port used to serve Postgres, defaults to '5432'
+        --hasura-port   TEXT    Port used to serve Hasura, defaults to '3001'
+        --graphql-port  TEXT    Port used to serve the GraphQL API, defaults to '4001'
+        --ui-port       TEXT    Port used to serve the UI, defaults to '8080'
+        --server-port   TEXT    Port used to serve the Core server, defaults to '4200'
+
+    \b
+        --no-postgres-port      Disable port map of Postgres to host
+        --no-hasura-port        Disable port map of Hasura to host
+        --no-graphql-port       Disable port map of the GraphQL API to host
+        --no-ui-port            Disable port map of the UI to host
+        --no-server-port        Disable port map of the Core server to host
     """
+
     docker_dir = Path(__file__).parents[0]
+    compose_dir_path = docker_dir
+
+    # Remove port mappings if specified
+    if (
+        no_postgres_port
+        or no_hasura_port
+        or no_graphql_port
+        or no_ui_port
+        or no_server_port
+    ):
+        temp_dir = tempfile.gettempdir()
+        temp_path = os.path.join(temp_dir, "docker-compose.yml")
+        shutil.copy2(os.path.join(docker_dir, "docker-compose.yml"), temp_path)
+
+        with open(temp_path, "r") as file:
+            y = yaml.load(file)
+
+            if no_postgres_port:
+                del y["services"]["postgres"]["ports"]
+
+            if no_hasura_port:
+                del y["services"]["hasura"]["ports"]
+
+            if no_graphql_port:
+                del y["services"]["graphql"]["ports"]
 
-    env = make_env()
+            if no_ui_port:
+                del y["services"]["ui"]["ports"]
+
+            if no_server_port:
+                del y["services"]["apollo"]["ports"]
+
+        with open(temp_path, "w") as f:
+            y = yaml.dump(y, f)
+
+        compose_dir_path = temp_dir
+
+    # Temporary config set for port allocation
+    with set_temporary_config(
+        {
+            "server.database.host_port": postgres_port,
+            "server.hasura.host_port": hasura_port,
+            "server.graphql.host_port": graphql_port,
+            "server.ui.host_port": ui_port,
+            "server.host_port": server_port,
+        }
+    ):
+        env = make_env()
 
     if "PREFECT_SERVER_TAG" not in env:
         env.update(PREFECT_SERVER_TAG=version)
@@ -117,12 +279,14 @@ def start(version, skip_pull, no_upgrade, no_ui):
     proc = None
     try:
         if not skip_pull:
-            subprocess.check_call(["docker-compose", "pull"], cwd=docker_dir, env=env)
+            subprocess.check_call(
+                ["docker-compose", "pull"], cwd=compose_dir_path, env=env
+            )
 
         cmd = ["docker-compose", "up"]
         if no_ui:
             cmd += ["--scale", "ui=0"]
-        proc = subprocess.Popen(cmd, cwd=docker_dir, env=env)
+        proc = subprocess.Popen(cmd, cwd=compose_dir_path, env=env)
         while True:
             time.sleep(0.5)
     except:
@@ -131,7 +295,9 @@ def start(version, skip_pull, no_upgrade, no_ui):
             fg="white",
             bg="red",
         )
-        subprocess.check_output(["docker-compose", "down"], cwd=docker_dir, env=env)
+        subprocess.check_output(
+            ["docker-compose", "down"], cwd=compose_dir_path, env=env
+        )
         if proc:
             proc.kill()
         raise


Test patch:
diff --git a/tests/cli/test_server.py b/tests/cli/test_server.py
new file mode 100644
--- /dev/null
+++ b/tests/cli/test_server.py
@@ -0,0 +1,202 @@
+import subprocess
+from unittest.mock import MagicMock
+
+from click.testing import CliRunner
+
+from prefect.cli.server import server, make_env
+from prefect.utilities.configuration import set_temporary_config
+
+
+def test_server_init():
+    runner = CliRunner()
+    result = runner.invoke(server)
+    assert result.exit_code == 0
+    assert "Commands for interacting with the Prefect Core server" in result.output
+
+
+def test_server_help():
+    runner = CliRunner()
+    result = runner.invoke(server, ["--help"])
+    assert result.exit_code == 0
+    assert "Commands for interacting with the Prefect Core server" in result.output
+
+
+def test_make_env():
+    env = make_env()
+    assert env
+
+
+def test_make_env_config_vars():
+    with set_temporary_config(
+        {
+            "server.database.connection_url": "localhost",
+            "server.graphql.host_port": "1",
+            "server.ui.host_port": "2",
+            "server.hasura.port": "3",
+            "server.graphql.port": "4",
+            "server.graphql.path": "/path",
+            "server.host_port": "5",
+            "server.database.host_port": "6",
+            "server.database.username": "username",
+            "server.database.password": "password",
+            "server.database.name": "db",
+            "server.hasura.host_port": "7",
+        }
+    ):
+        env = make_env()
+
+        assert env["DB_CONNECTION_URL"] == "postgres"
+        assert env["GRAPHQL_HOST_PORT"] == "1"
+        assert env["UI_HOST_PORT"] == "2"
+        assert env["HASURA_API_URL"] == "http://hasura:3/v1alpha1/graphql"
+        assert env["HASURA_WS_URL"] == "ws://hasura:3/v1alpha1/graphql"
+        assert env["PREFECT_API_URL"] == "http://graphql:4/path"
+        assert env["PREFECT_API_HEALTH_URL"] == "http://graphql:4/health"
+        assert env["APOLLO_HOST_PORT"] == "5"
+        assert env["POSTGRES_HOST_PORT"] == "6"
+        assert env["POSTGRES_USER"] == "username"
+        assert env["POSTGRES_PASSWORD"] == "password"
+        assert env["POSTGRES_DB"] == "db"
+        assert env["HASURA_HOST_PORT"] == "7"
+
+
+def test_server_start(monkeypatch):
+    check_call = MagicMock()
+    popen = MagicMock(side_effect=KeyboardInterrupt())
+    check_output = MagicMock()
+    monkeypatch.setattr("subprocess.Popen", popen)
+    monkeypatch.setattr("subprocess.check_call", check_call)
+    monkeypatch.setattr("subprocess.check_output", check_output)
+
+    runner = CliRunner()
+    result = runner.invoke(server, ["start"])
+    assert result.exit_code == 1
+
+    assert check_call.called
+    assert popen.called
+    assert check_output.called
+
+    assert check_call.call_args[0][0] == ["docker-compose", "pull"]
+    assert check_call.call_args[1].get("cwd")
+    assert check_call.call_args[1].get("env")
+
+    assert popen.call_args[0][0] == ["docker-compose", "up"]
+    assert popen.call_args[1].get("cwd")
+    assert popen.call_args[1].get("env")
+
+    assert check_output.call_args[0][0] == ["docker-compose", "down"]
+    assert check_output.call_args[1].get("cwd")
+    assert check_output.call_args[1].get("env")
+
+
+def test_server_start_options_and_flags(monkeypatch):
+    check_call = MagicMock()
+    popen = MagicMock(side_effect=KeyboardInterrupt())
+    check_output = MagicMock()
+    monkeypatch.setattr("subprocess.Popen", popen)
+    monkeypatch.setattr("subprocess.check_call", check_call)
+    monkeypatch.setattr("subprocess.check_output", check_output)
+
+    runner = CliRunner()
+    result = runner.invoke(
+        server,
+        ["start", "--version", "version", "--skip-pull", "--no-upgrade", "--no-ui"],
+    )
+    assert result.exit_code == 1
+
+    assert not check_call.called
+    assert popen.called
+    assert check_output.called
+
+    assert popen.call_args[0][0] == ["docker-compose", "up", "--scale", "ui=0"]
+    assert popen.call_args[1].get("cwd")
+    assert popen.call_args[1].get("env")
+    assert popen.call_args[1]["env"].get("PREFECT_SERVER_TAG") == "version"
+    assert (
+        popen.call_args[1]["env"].get("PREFECT_SERVER_DB_CMD")
+        == "echo 'DATABASE MIGRATIONS SKIPPED'"
+    )
+
+    assert check_output.call_args[0][0] == ["docker-compose", "down"]
+    assert check_output.call_args[1].get("cwd")
+    assert check_output.call_args[1].get("env")
+
+
+def test_server_start_port_options(monkeypatch):
+    check_call = MagicMock()
+    popen = MagicMock(side_effect=KeyboardInterrupt())
+    check_output = MagicMock()
+    monkeypatch.setattr("subprocess.Popen", popen)
+    monkeypatch.setattr("subprocess.check_call", check_call)
+    monkeypatch.setattr("subprocess.check_output", check_output)
+
+    runner = CliRunner()
+    result = runner.invoke(
+        server,
+        [
+            "start",
+            "--postgres-port",
+            "1",
+            "--hasura-port",
+            "2",
+            "--graphql-port",
+            "3",
+            "--ui-port",
+            "4",
+            "--server-port",
+            "5",
+        ],
+    )
+    assert result.exit_code == 1
+
+    assert check_call.called
+    assert popen.called
+    assert check_output.called
+
+    assert popen.call_args[0][0] == ["docker-compose", "up"]
+    assert popen.call_args[1].get("cwd")
+    assert popen.call_args[1].get("env")
+    assert popen.call_args[1]["env"].get("POSTGRES_HOST_PORT") == "1"
+    assert popen.call_args[1]["env"].get("HASURA_HOST_PORT") == "2"
+    assert popen.call_args[1]["env"].get("GRAPHQL_HOST_PORT") == "3"
+    assert popen.call_args[1]["env"].get("UI_HOST_PORT") == "4"
+    assert popen.call_args[1]["env"].get("APOLLO_HOST_PORT") == "5"
+
+
+def test_server_start_disable_port_mapping(monkeypatch):
+    check_call = MagicMock()
+    popen = MagicMock(side_effect=KeyboardInterrupt())
+    check_output = MagicMock()
+    monkeypatch.setattr("subprocess.Popen", popen)
+    monkeypatch.setattr("subprocess.check_call", check_call)
+    monkeypatch.setattr("subprocess.check_output", check_output)
+
+    runner = CliRunner()
+    result = runner.invoke(
+        server,
+        [
+            "start",
+            "--no-postgres-port",
+            "--no-hasura-port",
+            "--no-graphql-port",
+            "--no-ui-port",
+            "--no-server-port",
+        ],
+    )
+    assert result.exit_code == 1
+
+    assert check_call.called
+    assert popen.called
+    assert check_output.called
+
+    assert check_call.call_args[0][0] == ["docker-compose", "pull"]
+    assert check_call.call_args[1].get("cwd")
+    assert check_call.call_args[1].get("env")
+
+    assert popen.call_args[0][0] == ["docker-compose", "up"]
+    assert popen.call_args[1].get("cwd")
+    assert popen.call_args[1].get("env")
+
+    assert check_output.call_args[0][0] == ["docker-compose", "down"]
+    assert check_output.call_args[1].get("cwd")
+    assert check_output.call_args[1].get("env")
diff --git a/tests/core/test_flow.py b/tests/core/test_flow.py
--- a/tests/core/test_flow.py
+++ b/tests/core/test_flow.py
@@ -1605,7 +1605,7 @@ def test_flow_dot_run_passes_scheduled_parameters(self):
             [pendulum.now("UTC").add(seconds=0.1)], parameter_defaults=dict(x=1)
         )
         b = prefect.schedules.clocks.DatesClock(
-            [pendulum.now("UTC").add(seconds=0.25)], parameter_defaults=dict(x=2)
+            [pendulum.now("UTC").add(seconds=0.5)], parameter_defaults=dict(x=2)
         )
 
         x = prefect.Parameter("x", default=None, required=False)