Repository: meltano/meltano

Issue:
CLI: `meltano cloud schedule enable|disable`
For Meltano Cloud users, this will enable or disable a schedule.

Requires:

- https://github.com/meltano/infra/issues/365 (internal link)


Discussion:
As discussed, I'm moving this up in priority, with this approach (explicit enable/disable operation) and its backend API (https://github.com/meltano/infra/issues/365) replacing these in the priority level:

- https://github.com/meltano/infra/issues/384
- https://github.com/meltano/infra/issues/326


Resolving patch:
diff --git a/src/cloud-cli/meltano/cloud/api/client/__init__.py b/src/cloud-cli/meltano/cloud/api/client.py
similarity index 79%
rename from src/cloud-cli/meltano/cloud/api/client/__init__.py
rename to src/cloud-cli/meltano/cloud/api/client.py
--- a/src/cloud-cli/meltano/cloud/api/client/__init__.py
+++ b/src/cloud-cli/meltano/cloud/api/client.py
@@ -6,6 +6,7 @@
 import sys
 import typing as t
 from contextlib import asynccontextmanager, contextmanager, suppress
+from http import HTTPStatus
 from urllib.parse import urljoin
 
 from aiohttp import ClientResponse, ClientResponseError, ClientSession
@@ -40,7 +41,7 @@ def __init__(self, response: ClientResponse) -> None:
         super().__init__(response.reason)
 
 
-class MeltanoCloudClient:  # noqa: WPS214
+class MeltanoCloudClient:  # noqa: WPS214, WPS230
     """Client for the Meltano Cloud API.
 
     Attributes:
@@ -60,6 +61,10 @@ def __init__(self, config: MeltanoCloudConfig | None = None) -> None:
         self.runner_api_url = self.config.runner_api_url
         self.api_key = self.config.runner_api_key
         self.runner_secret = self.config.runner_secret
+        try:
+            self.version = version("meltano.cloud.cli")
+        except Exception:
+            self.version = version("meltano")
 
     async def __aenter__(self) -> MeltanoCloudClient:
         """Enter the client context.
@@ -71,7 +76,7 @@ async def __aenter__(self) -> MeltanoCloudClient:
         self._session.headers.update(
             {
                 "Content-Type": "application/json",
-                "User-Agent": f"Meltano Cloud CLI/v{version('meltano.cloud.cli')}",
+                "User-Agent": f"Meltano Cloud CLI/v{self.version}",
             }
         )
         return self
@@ -162,11 +167,7 @@ async def _raw_request(
             MeltanoCloudError: If the response status is not OK.
         """
         url = urljoin(base_url if base_url else self.api_url, path)
-        logger.debug(
-            "Making request",
-            method=method,
-            url=url,
-        )
+        logger.debug("Making Cloud CLI HTTP request", method=method, url=url)
         async with self.session.request(method, url, **kwargs) as response:
             try:
                 response.raise_for_status()
@@ -193,12 +194,7 @@ async def _json_request(
             The response JSON if response body is json,
             else the response content as a string.
         """
-        async with self._raw_request(
-            method,
-            path,
-            base_url=base_url,
-            **kwargs,
-        ) as response:
+        async with self._raw_request(method, path, base_url, **kwargs) as response:
             content = await response.content.read()
             decoded_content = content.decode()
             try:
@@ -256,6 +252,48 @@ async def run_project(
                 base_url=self.runner_api_url,
             )
 
+    async def schedule_put_enabled(
+        self,
+        *,
+        deployment: str,
+        schedule: str,
+        enabled: bool,
+    ):
+        """Use PUT to update the enabled state of a Meltano Cloud project schedule.
+
+        Args:
+            deployment: The name of the deployment the schedule belongs to.
+            schedule: The name of the schedule to enable/disable.
+            enabled: Whether the schedule should be enabled.
+
+        Raises:
+            MeltanoCloudError: The Meltano Cloud API responded with an error.
+        """
+        async with self.authenticated():
+            try:
+                await self._json_request(
+                    "PUT",
+                    (
+                        "/schedules/v1/"
+                        f"{self.config.tenant_resource_key}/"
+                        f"{self.config.internal_project_id}/"
+                        "enabled"
+                    ),
+                    json={
+                        "deployment_name": deployment,
+                        "schedule_name": schedule,
+                        "enabled": enabled,
+                    },
+                )
+            except MeltanoCloudError as ex:
+                if ex.response.status == HTTPStatus.NOT_FOUND:
+                    ex.response.reason = (
+                        f"Unable to find schedule with name {schedule!r} "
+                        f"within a deployment named {deployment!r}"
+                    )
+                    raise MeltanoCloudError(ex.response) from ex
+                raise
+
     @asynccontextmanager
     async def stream_logs(
         self,
diff --git a/src/cloud-cli/meltano/cloud/cli/__init__.py b/src/cloud-cli/meltano/cloud/cli/__init__.py
--- a/src/cloud-cli/meltano/cloud/cli/__init__.py
+++ b/src/cloud-cli/meltano/cloud/cli/__init__.py
@@ -6,7 +6,7 @@
 from structlog import get_logger
 
 from meltano.cloud.api import MeltanoCloudError
-from meltano.cloud.cli import logs, run  # noqa: F410
+from meltano.cloud.cli import logs, run, schedule
 from meltano.cloud.cli.base import cloud
 
 logger = get_logger()
diff --git a/src/cloud-cli/meltano/cloud/cli/schedule.py b/src/cloud-cli/meltano/cloud/cli/schedule.py
new file mode 100644
--- /dev/null
+++ b/src/cloud-cli/meltano/cloud/cli/schedule.py
@@ -0,0 +1,80 @@
+"""Meltano Cloud `schedules` command."""
+
+from __future__ import annotations
+
+import asyncio
+import typing as t
+
+import click
+
+from meltano.cloud.api.client import MeltanoCloudClient
+from meltano.cloud.cli.base import cloud
+
+if t.TYPE_CHECKING:
+    from meltano.cloud.api.config import MeltanoCloudConfig
+
+
+@cloud.group(name="schedule")
+@click.option(
+    "--deployment",
+    required=True,
+    help="The Meltano Cloud deployment the schedule is for.",
+)
+@click.pass_context
+def schedule_group(context: click.Context, deployment: str) -> None:
+    """Interact with Meltano Cloud project schedules."""
+    context.ensure_object(dict)
+    context.obj["deployment"] = deployment
+
+
+async def set_enabled_state(
+    *,
+    config: MeltanoCloudConfig,
+    deployment: str,
+    schedule: str,
+    enabled: bool,
+):
+    """Update the enabled state of a Meltano Cloud project schedule.
+
+    Args:
+        config: The Meltano Cloud config.
+        deployment: The name of the deployment the schedule belongs to.
+        schedule: The name of the schedule to enable/disable.
+        enabled: Whether the schedule should be enabled.
+    """
+    async with MeltanoCloudClient(config=config) as client:
+        await client.schedule_put_enabled(
+            deployment=deployment,
+            schedule=schedule,
+            enabled=enabled,
+        )
+
+
+@schedule_group.command("enable")
+@click.argument("schedule")
+@click.pass_context
+def enable(context: click.Context, schedule: str) -> None:
+    """Enable a Meltano Cloud schedule."""
+    asyncio.run(
+        set_enabled_state(
+            config=context.obj["config"],
+            deployment=context.obj["deployment"],
+            schedule=schedule,
+            enabled=True,
+        )
+    )
+
+
+@schedule_group.command("disable")
+@click.argument("schedule")
+@click.pass_context
+def disable(context: click.Context, schedule: str) -> None:
+    """Disable a Meltano Cloud schedule."""
+    asyncio.run(
+        set_enabled_state(
+            config=context.obj["config"],
+            deployment=context.obj["deployment"],
+            schedule=schedule,
+            enabled=False,
+        )
+    )


Test patch:
diff --git a/src/cloud-cli/tests/api/auth/test_auth.py b/src/cloud-cli/tests/api/auth/test_auth.py
--- a/src/cloud-cli/tests/api/auth/test_auth.py
+++ b/src/cloud-cli/tests/api/auth/test_auth.py
@@ -21,7 +21,7 @@ def config(self, tmp_path: Path):
 
     @pytest.fixture
     def subject(self, monkeypatch: pytest.MonkeyPatch, config: MeltanoCloudConfig):
-        monkeypatch.setenv("MELTANO_CLOUD_AUTH_CALLBACK_PORT", 8080)
+        monkeypatch.setenv("MELTANO_CLOUD_AUTH_CALLBACK_PORT", "8080")
         monkeypatch.setenv("MELTANO_CLOUD_BASE_AUTH_URL", "http://meltano-cloud-test")
         monkeypatch.setenv("MELTANO_CLOUD_APP_CLIENT_ID", "meltano-cloud-test")
         return MeltanoCloudAuth(config=config)
diff --git a/src/cloud-cli/tests/cli/conftest.py b/src/cloud-cli/tests/cli/conftest.py
--- a/src/cloud-cli/tests/cli/conftest.py
+++ b/src/cloud-cli/tests/cli/conftest.py
@@ -37,7 +37,7 @@ def id_token(tenant_resource_key: str, internal_project_id: str):
 
 
 @pytest.fixture
-def config(config_path: Path, id_token: str):
+def config(config_path: Path, id_token: str) -> MeltanoCloudConfig:
     """Return a MeltanoCloudConfig instance with fake credentials."""
     config = MeltanoCloudConfig(  # noqa: S106
         config_path=config_path,
@@ -52,6 +52,6 @@ def config(config_path: Path, id_token: str):
 
 
 @pytest.fixture
-def client(config: MeltanoCloudConfig):
+def client(config: MeltanoCloudConfig) -> MeltanoCloudClient:
     """Return a MeltanoCloudClient instance with fake credentials."""
     return MeltanoCloudClient(config=config)
diff --git a/src/cloud-cli/tests/cli/test_schedules.py b/src/cloud-cli/tests/cli/test_schedules.py
new file mode 100644
--- /dev/null
+++ b/src/cloud-cli/tests/cli/test_schedules.py
@@ -0,0 +1,52 @@
+"""Test the schedule command."""
+
+from __future__ import annotations
+
+import json
+import typing as t
+from urllib.parse import urljoin
+
+from aioresponses import aioresponses
+from click.testing import CliRunner
+
+from meltano.cloud.cli import cloud as cli
+
+if t.TYPE_CHECKING:
+    from meltano.cloud.api import MeltanoCloudClient
+    from meltano.cloud.api.config import MeltanoCloudConfig
+
+
+class TestScheduleCommand:
+    """Test the logs command."""
+
+    def test_schedule_enable(
+        self,
+        tenant_resource_key: str,
+        internal_project_id: str,
+        client: MeltanoCloudClient,
+        config: MeltanoCloudConfig,
+    ):
+        path = f"schedules/v1/{tenant_resource_key}/{internal_project_id}/enabled"
+        runner = CliRunner()
+        for cmd in ("enable", "disable"):
+            with aioresponses() as m:
+                m.get(
+                    f"{config.base_auth_url}/oauth2/userInfo",
+                    status=200,
+                    body=json.dumps({"sub": "meltano-cloud-test"}),
+                )
+                m.put(urljoin(client.api_url, path), status=204)
+                result = runner.invoke(
+                    cli,
+                    (
+                        "--config-path",
+                        config.config_path,
+                        "schedule",
+                        "--deployment",
+                        "dev",
+                        cmd,
+                        "daily",
+                    ),
+                )
+                assert result.exit_code == 0
+                assert not result.output
diff --git a/src/cloud-cli/tests/conftest.py b/src/cloud-cli/tests/conftest.py
--- a/src/cloud-cli/tests/conftest.py
+++ b/src/cloud-cli/tests/conftest.py
@@ -4,6 +4,7 @@
 from pathlib import Path
 
 import pytest
+from pytest_structlog import StructuredLogCapture
 
 
 @pytest.fixture(scope="session", autouse=True)
@@ -14,3 +15,11 @@ def config_path(tmpdir_factory: pytest.TempdirFactory) -> Path:
     filepath.touch()
     os.environ["MELTANO_CLOUD_CONFIG_PATH"] = str(filepath)
     return filepath
+
+
+# Define this fixture to make all tests auto-use the structlog capture fixture.
+# This ensures that log messages are not mixed with other output, yet can still
+# be tested.
+@pytest.fixture(autouse=True)
+def log(log: StructuredLogCapture) -> StructuredLogCapture:
+    return log