Repository: airtai/fastagency

Issue:
Create fastagency CLI for docker build, run and deploy

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/airtai/fastagency/pull/508?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=airtai) Report
All modified and coverable lines are covered by tests :white_check_mark:

| [Files with missing lines](https://app.codecov.io/gh/airtai/fastagency/pull/508?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=airtai) | Coverage Δ | |
|---|---|---|
| [fastagency/cli/cli.py](https://app.codecov.io/gh/airtai/fastagency/pull/508?src=pr&el=tree&filepath=fastagency%2Fcli%2Fcli.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=airtai#diff-ZmFzdGFnZW5jeS9jbGkvY2xpLnB5) | `71.42% <100.00%> (+0.69%)` | :arrow_up: |
| [fastagency/cli/docker\_cli.py](https://app.codecov.io/gh/airtai/fastagency/pull/508?src=pr&el=tree&filepath=fastagency%2Fcli%2Fdocker_cli.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=airtai#diff-ZmFzdGFnZW5jeS9jbGkvZG9ja2VyX2NsaS5weQ==) | `100.00% <100.00%> (ø)` | |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/airtai/fastagency/pull/508/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=airtai)


Patch:
diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md
index 81538181..f6daa36f 100644
--- a/docs/docs/SUMMARY.md
+++ b/docs/docs/SUMMARY.md
@@ -110,6 +110,10 @@ search:
                     - [get_import_string](api/fastagency/cli/discover/get_import_string.md)
                     - [get_module_data_from_path](api/fastagency/cli/discover/get_module_data_from_path.md)
                     - [import_from_string](api/fastagency/cli/discover/import_from_string.md)
+                - docker_cli
+                    - [build](api/fastagency/cli/docker_cli/build.md)
+                    - [deploy](api/fastagency/cli/docker_cli/deploy.md)
+                    - [run](api/fastagency/cli/docker_cli/run.md)
                 - logging
                     - [setup_logging](api/fastagency/cli/logging/setup_logging.md)
             - exceptions
diff --git a/docs/docs/en/api/fastagency/cli/docker_cli/build.md b/docs/docs/en/api/fastagency/cli/docker_cli/build.md
new file mode 100644
index 00000000..a1d724ae
--- /dev/null
+++ b/docs/docs/en/api/fastagency/cli/docker_cli/build.md
@@ -0,0 +1,11 @@
+---
+# 0.5 - API
+# 2 - Release
+# 3 - Contributing
+# 5 - Template Page
+# 10 - Default
+search:
+  boost: 0.5
+---
+
+::: fastagency.cli.docker_cli.build
diff --git a/docs/docs/en/api/fastagency/cli/docker_cli/deploy.md b/docs/docs/en/api/fastagency/cli/docker_cli/deploy.md
new file mode 100644
index 00000000..5700bf48
--- /dev/null
+++ b/docs/docs/en/api/fastagency/cli/docker_cli/deploy.md
@@ -0,0 +1,11 @@
+---
+# 0.5 - API
+# 2 - Release
+# 3 - Contributing
+# 5 - Template Page
+# 10 - Default
+search:
+  boost: 0.5
+---
+
+::: fastagency.cli.docker_cli.deploy
diff --git a/docs/docs/en/api/fastagency/cli/docker_cli/run.md b/docs/docs/en/api/fastagency/cli/docker_cli/run.md
new file mode 100644
index 00000000..9cb1822a
--- /dev/null
+++ b/docs/docs/en/api/fastagency/cli/docker_cli/run.md
@@ -0,0 +1,11 @@
+---
+# 0.5 - API
+# 2 - Release
+# 3 - Contributing
+# 5 - Template Page
+# 10 - Default
+search:
+  boost: 0.5
+---
+
+::: fastagency.cli.docker_cli.run
diff --git a/docs/docs/en/cli/cli.md b/docs/docs/en/cli/cli.md
index 12877990..f4c21c47 100644
--- a/docs/docs/en/cli/cli.md
+++ b/docs/docs/en/cli/cli.md
@@ -20,6 +20,7 @@ $ fastagency [OPTIONS] COMMAND [ARGS]...
 **Commands**:
 
 * `dev`: Run a **FastAgency** app in <code>development</code> mode.
+* `docker`: **Docker** commands for...
 * `run`: Run a **FastAgency** app in <code>production</code> mode.
 * `version`: Display the version of FastAgency
 
@@ -66,6 +67,90 @@ $ fastagency dev [OPTIONS] [PATH]
 * `--single-run`: If set, only a single workflow will be executed.
 * `--help`: Show this message and exit.
 
+## `fastagency docker`
+
+**Docker** commands for **FastAgency**
+
+**Usage**:
+
+```console
+$ fastagency docker [OPTIONS] COMMAND [ARGS]...
+```
+
+**Options**:
+
+* `--help`: Show this message and exit.
+
+**Commands**:
+
+* `build`: Build a Docker image for the FastAgency app
+* `deploy`: Deploy the Docker container for the...
+* `run`: Run a Docker container for the FastAgency app
+
+### `fastagency docker build`
+
+Build a Docker image for the FastAgency app
+
+**Usage**:
+
+```console
+$ fastagency docker build [OPTIONS] [BUILD_CONTEXT]
+```
+
+**Arguments**:
+
+* `[BUILD_CONTEXT]`: Docker build context  [default: .]
+
+**Options**:
+
+* `-f, --file TEXT`: Name of the Dockerfile  [default: docker/Dockerfile]
+* `-t, --tag TEXT`: Name and optionally a tag (format: "name:tag")  [default: deploy_fastagency]
+* `--progress TEXT`: Set type of progress output (auto, plain, tty, rawjson).  [default: plain]
+* `--help`: Show this message and exit.
+
+### `fastagency docker deploy`
+
+Deploy the Docker container for the FastAgency app to Fly.io
+
+**Usage**:
+
+```console
+$ fastagency docker deploy [OPTIONS] [CONFIG_FILE]
+```
+
+**Arguments**:
+
+* `[CONFIG_FILE]`: The Fly.io configuration file  [default: fly.toml]
+
+**Options**:
+
+* `--openai-api-key TEXT`: OpenAI API key  [env var: OPENAI_API_KEY; required]
+* `--help`: Show this message and exit.
+
+### `fastagency docker run`
+
+Run a Docker container for the FastAgency app
+
+**Usage**:
+
+```console
+$ fastagency docker run [OPTIONS] [IMAGE]
+```
+
+**Arguments**:
+
+* `[IMAGE]`: The Docker image to run  [default: deploy_fastagency]
+
+**Options**:
+
+* `--name TEXT`: Assign a name to the container  [default: deploy_fastagency]
+* `-e, --env TEXT`: Set environment variables
+* `-p, --publish TEXT`: Publish a container's port(s) to the host
+* `--rm`: Automatically remove the container and its associated anonymous volumes when it exits
+* `-d, --detach`: Run container in background and print container ID  [default: True]
+* `--network TEXT`: Connect a container to a network
+* `--help`: Show this message and exit.
+
 ## `fastagency run`
 
 Run a **FastAgency** app in <code>production</code> mode. 🚀
diff --git a/fastagency/cli/cli.py b/fastagency/cli/cli.py
index 916af095..56228f59 100644
--- a/fastagency/cli/cli.py
+++ b/fastagency/cli/cli.py
@@ -7,9 +7,15 @@ import typer
 from .. import __version__
 from ..exceptions import FastAgencyCLIError, FastAgencyCLIPythonVersionError
 from .discover import get_import_string
+from .docker_cli import docker_app
 from .logging import setup_logging
 
 app = typer.Typer(rich_markup_mode="rich")
+app.add_typer(
+    docker_app,
+    name="docker",
+    help="[bold]Docker[/bold] commands for [bold]FastAgency[/bold]",
+)
 
 setup_logging()
 logger = getLogger(__name__)
diff --git a/fastagency/cli/docker_cli.py b/fastagency/cli/docker_cli.py
new file mode 100644
index 00000000..39dbbcf0
--- /dev/null
+++ b/fastagency/cli/docker_cli.py
@@ -0,0 +1,243 @@
+import subprocess  # nosec B404
+from logging import getLogger
+from typing import Annotated, Optional
+
+import typer
+
+from .logging import setup_logging
+
+docker_app = typer.Typer(rich_markup_mode="rich")
+
+
+setup_logging()
+logger = getLogger(__name__)
+
+
+@docker_app.command(
+    context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
+    help="Build a Docker image for the FastAgency app",
+)
+def build(
+    build_context: Annotated[
+        str,
+        typer.Argument(
+            ...,
+            help="Docker build context",
+        ),
+    ] = ".",
+    *,
+    file: Annotated[
+        str,
+        typer.Option(
+            "--file",
+            "-f",
+            help="Name of the Dockerfile",
+        ),
+    ] = "docker/Dockerfile",
+    tag: Annotated[
+        str,
+        typer.Option(
+            "--tag",
+            "-t",
+            help='Name and optionally a tag (format: "name:tag")',
+        ),
+    ] = "deploy_fastagency",
+    progress: Annotated[
+        str,
+        typer.Option(
+            "--progress",
+            help="Set type of progress output (auto, plain, tty, rawjson).",
+        ),
+    ] = "plain",
+    ctx: typer.Context,
+) -> None:
+    command = [
+        "docker",
+        "build",
+        "-t",
+        tag,
+        "-f",
+        file,
+        "--progress",
+        progress,
+        build_context,
+    ]
+    command += ctx.args
+    typer.echo(
+        f"Building FastAgency Docker image with the command: {' '.join(command)}"
+    )
+    try:
+        # Run the docker build command
+        result = subprocess.run(  # nosec B603
+            command, check=True, capture_output=True, text=True
+        )
+        typer.echo(result.stdout)
+        typer.echo(f"Image '{tag}' built successfully!")
+    except subprocess.CalledProcessError as e:
+        typer.echo(f"Error: {e.stderr}", err=True)
+        raise typer.Exit(code=1) from e
+
+
+@docker_app.command(
+    context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
+    help="Run a Docker container for the FastAgency app",
+)
+def run(
+    image: Annotated[
+        str,
+        typer.Argument(
+            ...,
+            help="The Docker image to run",
+        ),
+    ] = "deploy_fastagency",
+    *,
+    name: Annotated[
+        str,
+        typer.Option(
+            "--name",
+            help="Assign a name to the container",
+        ),
+    ] = "deploy_fastagency",
+    env: Annotated[
+        Optional[list[str]],
+        typer.Option(
+            "--env",
+            "-e",
+            help="Set environment variables",
+            show_default=False,
+        ),
+    ] = None,
+    publish: Annotated[
+        Optional[list[str]],
+        typer.Option(
+            "--publish",
+            "-p",
+            help="Publish a container's port(s) to the host",
+            show_default=False,
+        ),
+    ] = None,
+    remove: Annotated[
+        bool,
+        typer.Option(
+            "--rm",
+            help="Automatically remove the container and its associated anonymous volumes when it exits",
+            is_flag=True,
+        ),
+    ] = False,
+    detach: Annotated[
+        bool,
+        typer.Option(
+            "--detach",
+            "-d",
+            help="Run container in background and print container ID",
+            is_flag=True,
+        ),
+    ] = True,
+    network: Annotated[
+        Optional[str],
+        typer.Option(
+            "--network",
+            help="Connect a container to a network",
+            show_default=False,
+        ),
+    ] = None,
+    ctx: typer.Context,
+) -> None:
+    # Construct the docker run command using the provided options
+    command = ["docker", "run", "--name", name]
+
+    if env:
+        for env_var in env:
+            command.extend(["--env", env_var])
+
+    if publish:
+        for port in publish:
+            command.extend(["--publish", port])
+        if "8888:8888" not in publish:
+            command.extend(["--publish", "8888:8888"])
+    else:
+        command.extend(["--publish", "8888:8888"])
+
+    if remove:
+        command.append("--rm")
+
+    if detach:
+        command.append("--detach")
+
+    if network:
+        command.extend(["--network", network])
+
+    command += ctx.args
+    command.append(image)
+
+    try:
+        typer.echo(
+            f"Running FastAgency Docker image with the command: {' '.join(command)}"
+        )
+        # Run the docker command
+        result = subprocess.run(  # nosec B603
+            command, check=True, capture_output=True, text=True
+        )
+        typer.echo(result.stdout)
+    except subprocess.CalledProcessError as e:
+        typer.echo(f"Error: {e.stderr}", err=True)
+        raise typer.Exit(code=1) from e
+
+
+@docker_app.command(
+    context_settings={"allow_extra_args": False, "ignore_unknown_options": False},
+    help="Deploy the Docker container for the FastAgency app to Fly.io",
+)
+def deploy(
+    config_file: Annotated[
+        str,
+        typer.Argument(
+            ...,
+            help="The Fly.io configuration file",
+        ),
+    ] = "fly.toml",
+    *,
+    openai_api_key: Annotated[
+        str,
+        typer.Option(
+            "--openai-api-key",
+            help="OpenAI API key",
+            envvar="OPENAI_API_KEY",
+            show_default=False,
+        ),
+    ],
+    # ctx: typer.Context,
+) -> None:
+    launch_command = [
+        "fly",
+        "launch",
+        "--config",
+        config_file,
+        "--copy-config",
+        "--yes",
+    ]
+    # launch_command += ctx.args
+
+    set_secret_command = ["fly", "secrets", "set", "OPENAI_API_KEY=" + openai_api_key]
+    try:
+        typer.echo(
+            f"Deploying FastAgency Docker image to Fly.io with the command: {' '.join(launch_command)}"
+        )
+        # Run the fly deploy command
+        deploy_result = subprocess.run(  # nosec B603
+            launch_command, check=True, capture_output=True, text=True
+        )
+        typer.echo(deploy_result.stdout)
+
+        typer.echo(
+            f"Setting OpenAI API key with the command: {' '.join(set_secret_command)}"
+        )
+        # Run the fly secrets set command
+        set_secret_result = subprocess.run(  # nosec B603
+            set_secret_command, check=True, capture_output=True, text=True
+        )
+        typer.echo(set_secret_result.stdout)
+        typer.echo("Deployed FastAgency Docker image to Fly.io successfully!")
+    except subprocess.CalledProcessError as e:
+        typer.echo(f"Error: {e.stderr}", err=True)
+        raise typer.Exit(code=1) from e


Test patch:
diff --git a/tests/cli/test_docker_cli.py b/tests/cli/test_docker_cli.py
new file mode 100644
index 00000000..0ecb6de2
--- /dev/null
+++ b/tests/cli/test_docker_cli.py
@@ -0,0 +1,218 @@
+import platform
+import subprocess
+from typing import Any
+
+import pytest
+from typer.testing import CliRunner
+
+from fastagency.cli import app
+
+runner = CliRunner()
+
+
+@pytest.mark.parametrize(
+    "command,expected",  # noqa: PT006
+    [
+        (
+            ["docker", "build"],
+            [
+                "docker",
+                "build",
+                "-t",
+                "deploy_fastagency",
+                "-f",
+                "docker/Dockerfile",
+                "--progress",
+                "plain",
+                ".",
+            ],
+        ),
+        (
+            ["docker", "build", "--no-cache", "."],
+            [
+                "docker",
+                "build",
+                "-t",
+                "deploy_fastagency",
+                "-f",
+                "docker/Dockerfile",
+                "--progress",
+                "plain",
+                "--no-cache",
+                ".",
+            ],
+        ),
+    ],
+)
+def test_docker_build(
+    command: list[str], expected: list[str], monkeypatch: pytest.MonkeyPatch
+) -> None:
+    def patch_subprocess_run(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess:  # type: ignore[type-arg]
+        assert args[0] == expected
+        assert kwargs["check"]
+        assert kwargs["capture_output"]
+        assert kwargs["text"]
+
+        return subprocess.CompletedProcess(
+            args=command, returncode=0, stdout="Dummy docker build output"
+        )
+
+    monkeypatch.setattr(subprocess, "run", patch_subprocess_run)
+
+    result = runner.invoke(app, command)
+    assert result.exit_code == 0
+    assert "Building FastAgency Docker image" in result.stdout
+    assert " ".join(expected) in result.stdout
+
+
+@pytest.mark.skipif(
+    platform.system() == "Windows" or platform.system() == "Darwin",
+    reason="Docker not supported on Windows or macOS CI",
+)
+def test_docker_build_invalid_argument(monkeypatch: pytest.MonkeyPatch) -> None:
+    command = ["docker", "build", "--invalid-argument", "."]
+
+    result = runner.invoke(app, command)
+    assert result.exit_code != 0
+    assert "Building FastAgency Docker image" in result.stdout, result.stdout
+    assert "Error: unknown flag: --invalid-argument" in result.stdout, result.stdout
+
+
+@pytest.mark.parametrize(
+    "command,expected",  # noqa: PT006
+    [
+        (
+            ["docker", "run"],
+            [
+                "docker",
+                "run",
+                "--name",
+                "deploy_fastagency",
+                "--publish",
+                "8888:8888",
+                "--detach",
+                "deploy_fastagency",
+            ],
+        ),
+        (
+            ["docker", "run", "--rm"],
+            [
+                "docker",
+                "run",
+                "--name",
+                "deploy_fastagency",
+                "--publish",
+                "8888:8888",
+                "--rm",
+                "--detach",
+                "deploy_fastagency",
+            ],
+        ),
+        (
+            ["docker", "run", "--network", "host"],
+            [
+                "docker",
+                "run",
+                "--name",
+                "deploy_fastagency",
+                "--publish",
+                "8888:8888",
+                "--detach",
+                "--network",
+                "host",
+                "deploy_fastagency",
+            ],
+        ),
+    ],
+)
+def test_docker_run(
+    command: list[str], expected: list[str], monkeypatch: pytest.MonkeyPatch
+) -> None:
+    def patch_subprocess_run(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess:  # type: ignore[type-arg]
+        assert args[0] == expected
+        assert kwargs["check"]
+        assert kwargs["capture_output"]
+        assert kwargs["text"]
+
+        return subprocess.CompletedProcess(
+            args=command, returncode=0, stdout="Dummy docker run output"
+        )
+
+    monkeypatch.setattr(subprocess, "run", patch_subprocess_run)
+
+    result = runner.invoke(app, command)
+    assert result.exit_code == 0, result.stdout
+    assert "Running FastAgency Docker image" in result.stdout
+    assert " ".join(expected) in result.stdout
+
+
+@pytest.mark.skipif(
+    platform.system() == "Windows" or platform.system() == "Darwin",
+    reason="Docker not supported on Windows or macOS CI",
+)
+def test_docker_run_invalid_argument(monkeypatch: pytest.MonkeyPatch) -> None:
+    command = ["docker", "run", "--invalid-argument"]
+
+    result = runner.invoke(app, command)
+    assert result.exit_code != 0
+    assert "Running FastAgency Docker image" in result.stdout, result.stdout
+    assert "Error: unknown flag: --invalid-argument" in result.stdout, result.stdout
+
+
+@pytest.mark.parametrize(
+    "command,expected",  # noqa: PT006
+    [
+        (
+            ["docker", "deploy", "--openai-api-key", "dummy_key"],
+            [
+                "fly",
+                "launch",
+                "--config",
+                "fly.toml",
+                "--copy-config",
+                "--yes",
+            ],
+        ),
+        (
+            ["docker", "deploy", "--openai-api-key", "dummy_key", "fly.prod.toml"],
+            [
+                "fly",
+                "launch",
+                "--config",
+                "fly.prod.toml",
+                "--copy-config",
+                "--yes",
+            ],
+        ),
+    ],
+)
+def test_docker_deploy(
+    command: list[str], expected: list[str], monkeypatch: pytest.MonkeyPatch
+) -> None:
+    def patch_subprocess_run(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess:  # type: ignore[type-arg]
+        if args[0][1] == "launch":
+            assert args[0] == expected
+        elif args[0][1] == "secrets":
+            assert args[0][:3] == ["fly", "secrets", "set"]
+        assert kwargs["check"]
+        assert kwargs["capture_output"]
+        assert kwargs["text"]
+
+        return subprocess.CompletedProcess(
+            args=command, returncode=0, stdout="Dummy docker deploy output"
+        )
+
+    monkeypatch.setattr(subprocess, "run", patch_subprocess_run)
+
+    result = runner.invoke(app, command)
+    assert result.exit_code == 0, result.stdout
+    assert "Deploying FastAgency Docker image to Fly.io" in result.stdout
+    assert " ".join(expected) in result.stdout
+
+
+def test_docker_deploy_invalid_argument(monkeypatch: pytest.MonkeyPatch) -> None:
+    command = ["docker", "deploy", "--invalid-argument"]
+
+    result = runner.invoke(app, command)
+    assert result.exit_code != 0, result.stdout
+    assert "No such option:" in result.stdout, result.stdout