Repository: meltano/meltano

Issue:
bug: Schedules JSON schema doesn't accept cron expressions
### Meltano Version

NA

### Python Version

NA

### Bug scope

Other

### Operating System

Any

### Description

https://github.com/meltano/meltano/blob/3a6ff7e7e167f51b9a5570eeaee48faed032a9c0/schema/meltano.schema.json#L769-L774

### Code

_No response_


Discussion:
FYI @aaronsteers I've added to the next iteration
The schema states that we accept a UNIX cron expression, but in practice we accept whatever Airflow accepts, and Airflow accepts whatever [`croniter`](https://github.com/kiorky/croniter) accepts. I think resolving this issue should consist of these changes:
- Update `src/meltano/core/schedule.py::Schedule::cron_interval` to normalize the provided cron interval using `croniter` instead of the custom approach we currently use.
- Update `src/meltano/core/schedule.py::Schedule` to raise an error if the provided `interval` expression is invalid according to `croniter.croniter.is_valid` if it is not `None`.
- Create a regex for what `croniter` accepts, and update `schema/meltano.schema.json` to use it for the `interval` pattern. This doesn't have to be perfect, but should err towards accepting more that `croniter`, rather than declaring valid cron expressions as invalid.
- Add a parameterized test with many additional (valid and invalid) cron expressions...
  - for schema validation (to ensure the pattern is accurate).
  - for `Schedule` instance creation (to ensure validation is working as intended).

Resolving patch:
diff --git a/src/meltano/core/schedule.py b/src/meltano/core/schedule.py
--- a/src/meltano/core/schedule.py
+++ b/src/meltano/core/schedule.py
@@ -23,14 +23,14 @@ class Schedule(NameEq, Canonical):  # noqa: WPS230
 
     def __init__(
         self,
-        name: str = None,
-        extractor: str = None,
-        loader: str = None,
-        transform: str = None,
-        interval: str = None,
-        start_date: datetime = None,
-        job: str = None,
-        env: dict = None,
+        name: str | None = None,
+        extractor: str | None = None,
+        loader: str | None = None,
+        transform: str | None = None,
+        interval: str | None = None,
+        start_date: datetime.datetime | None = None,
+        job: str | None = None,
+        env: dict | None = None,
     ):
         """Initialize a Schedule.
 
diff --git a/src/meltano/core/schedule_service.py b/src/meltano/core/schedule_service.py
--- a/src/meltano/core/schedule_service.py
+++ b/src/meltano/core/schedule_service.py
@@ -6,6 +6,8 @@
 import subprocess
 from datetime import date, datetime
 
+from croniter import croniter
+
 from meltano.core.setting_definition import SettingMissingError
 
 from .meltano_invoker import MeltanoInvoker
@@ -56,6 +58,18 @@ def __init__(self, namespace: str):
         self.namespace = namespace
 
 
+class BadCronError(Exception):
+    """Occurs when a cron expression is invalid."""
+
+    def __init__(self, cron: str):
+        """Initialize the exception.
+
+        Args:
+            cron: The cron expression that is invalid.
+        """
+        self.cron = cron
+
+
 class ScheduleService:
     """Service for managing schedules."""
 
@@ -174,8 +188,16 @@ def add_schedule(self, schedule: Schedule) -> Schedule:
             The added schedule.
 
         Raises:
+            BadCronError: If the cron expression is invalid.
             ScheduleAlreadyExistsError: If a schedule with the same name already exists.
         """
+        if (
+            schedule.interval is not None
+            and schedule.interval != "@once"
+            and not croniter.is_valid(schedule.interval)
+        ):
+            raise BadCronError(schedule.interval)
+
         with self.project.meltano_update() as meltano:
             # guard if it already exists
             if schedule in meltano.schedules:


Test patch:
diff --git a/tests/meltano/cli/test_schedule.py b/tests/meltano/cli/test_schedule.py
--- a/tests/meltano/cli/test_schedule.py
+++ b/tests/meltano/cli/test_schedule.py
@@ -32,7 +32,7 @@ def test_schedule_add(self, get, session, project, cli_runner, schedule_service)
                     "--loader",
                     "target-mock",
                     "--interval",
-                    "@eon",
+                    "@yearly",
                     "--transform",
                     "run",
                 ],
@@ -45,7 +45,7 @@ def test_schedule_add(self, get, session, project, cli_runner, schedule_service)
         assert schedule.extractor == "tap-mock"
         assert schedule.loader == "target-mock"
         assert schedule.transform == "run"
-        assert schedule.interval == "@eon"  # not anytime soon ;)
+        assert schedule.interval == "@yearly"  # not anytime soon ;)
         assert schedule.start_date == iso8601_datetime(test_date)
 
         # test adding a scheduled job
@@ -61,7 +61,7 @@ def test_schedule_add(self, get, session, project, cli_runner, schedule_service)
                     "--job",
                     "mock-job",
                     "--interval",
-                    "@eon",
+                    "@yearly",
                 ],
             )
         assert res.exit_code == 0
@@ -71,7 +71,7 @@ def test_schedule_add(self, get, session, project, cli_runner, schedule_service)
 
         assert schedule.name == "job-schedule-mock"
         assert schedule.job == "mock-job"
-        assert schedule.interval == "@eon"  # not anytime soon ;)
+        assert schedule.interval == "@yearly"  # not anytime soon ;)
 
         # test default schedule case where no argument (set, remove, add, etc) is provided
         with mock.patch(
@@ -85,7 +85,7 @@ def test_schedule_add(self, get, session, project, cli_runner, schedule_service)
                     "--job",
                     "mock-job",
                     "--interval",
-                    "@eon",
+                    "@yearly",
                 ],
             )
         assert res.exit_code == 0
@@ -95,7 +95,7 @@ def test_schedule_add(self, get, session, project, cli_runner, schedule_service)
 
         assert schedule.name == "job-schedule-mock"
         assert schedule.job == "mock-job"
-        assert schedule.interval == "@eon"  # not anytime soon ;)
+        assert schedule.interval == "@yearly"  # not anytime soon ;)
 
         # verify that you can't use job and elt flags together
         with mock.patch(
@@ -114,7 +114,7 @@ def test_schedule_add(self, get, session, project, cli_runner, schedule_service)
                     "--loader",
                     "target-mock",
                     "--interval",
-                    "@eon",
+                    "@yearly",
                     "--transform",
                     "run",
                 ],
diff --git a/tests/meltano/core/test_schedule_service.py b/tests/meltano/core/test_schedule_service.py
--- a/tests/meltano/core/test_schedule_service.py
+++ b/tests/meltano/core/test_schedule_service.py
@@ -10,6 +10,7 @@
 from meltano.core.plugin.project_plugin import ProjectPlugin
 from meltano.core.project_plugins_service import PluginAlreadyAddedException
 from meltano.core.schedule_service import (
+    BadCronError,
     Schedule,
     ScheduleAlreadyExistsError,
     ScheduleDoesNotExistError,
@@ -88,6 +89,11 @@ def test_add_schedules(self, subject, create_elt_schedule, create_job_schedule):
         with pytest.raises(ScheduleAlreadyExistsError):
             subject.add_schedule(all_schedules[0])
 
+        with pytest.raises(BadCronError) as excinfo:
+            subject.add_schedule(create_elt_schedule("bad-cron", interval="bad_cron"))
+
+        assert "bad_cron" in str(excinfo.value)
+
     def test_remove_schedule(self, subject):
         if platform.system() == "Windows":
             pytest.xfail(
@@ -115,14 +121,14 @@ def test_remove_schedule(self, subject):
     def test_schedule_update(self, subject):
         schedule = subject.schedules()[0]
 
-        schedule.interval = "@pytest"
+        schedule.interval = "@yearly"
         subject.update_schedule(schedule)
 
         # there should be only 1 element with the set interval
-        assert sum(sbj.interval == "@pytest" for sbj in subject.schedules()) == 1
+        assert sum(sbj.interval == "@yearly" for sbj in subject.schedules()) == 1
 
         # it should be the first element
-        assert subject.schedules()[0].interval == "@pytest"
+        assert subject.schedules()[0].interval == "@yearly"
 
         # it should be a copy of the original element
         assert schedule is not subject.schedules()[0]