0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-04-08 06:30:05 +00:00

Add API support for OnCalendar schedules

This commit is contained in:
Pēteris Caune 2023-12-07 14:03:35 +02:00
parent 980520e3b2
commit fc56cf2635
No known key found for this signature in database
GPG key ID: E28D7679E9A9EDE2
4 changed files with 75 additions and 21 deletions

View file

@ -408,7 +408,7 @@ class Check(models.Model):
if self.kind == "simple":
result["timeout"] = int(self.timeout.total_seconds())
elif self.kind == "cron":
elif self.kind in ("cron", "oncalendar"):
result["schedule"] = self.schedule
result["tz"] = self.tz

View file

@ -314,7 +314,7 @@ class CreateCheckTestCase(BaseTestCase):
def test_it_validates_cron_expression(self) -> None:
r = self.post(
{"schedule": "bad-expression", "tz": "Europe/Riga", "grace": 60},
expect_fragment="schedule is not a valid cron expression",
expect_fragment="schedule is not a valid cron or OnCalendar expression",
)
self.assertEqual(r.status_code, 400)
@ -333,6 +333,24 @@ class CreateCheckTestCase(BaseTestCase):
)
self.assertEqual(r.status_code, 400)
def test_it_supports_oncalendar_syntax(self) -> None:
r = self.post({"schedule": "12:34", "tz": "Europe/Riga", "grace": 60})
self.assertEqual(r.status_code, 201)
doc = r.json()
self.assertEqual(doc["schedule"], "12:34")
self.assertEqual(doc["tz"], "Europe/Riga")
self.assertEqual(doc["grace"], 60)
self.assertTrue("timeout" not in doc)
def test_it_validates_oncalendar_expression(self) -> None:
r = self.post(
{"schedule": "12:34\nsurprise", "tz": "Europe/Riga", "grace": 60},
expect_fragment="schedule is not a valid cron or OnCalendar expression",
)
self.assertEqual(r.status_code, 400)
def test_it_sets_default_timeout(self) -> None:
r = self.post({})
self.assertEqual(r.status_code, 201)

View file

@ -110,6 +110,18 @@ class UpdateCheckTestCase(BaseTestCase):
self.check.refresh_from_db()
self.assertEqual(self.check.kind, "simple")
def test_it_updates_cron_to_oncalendar(self) -> None:
self.check.kind = "cron"
self.check.schedule = "5 * * * *"
self.check.save()
r = self.post(self.check.code, {"schedule": "12:34"})
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
self.assertEqual(self.check.kind, "oncalendar")
self.assertEqual(self.check.schedule, "12:34")
def test_it_sets_single_channel(self) -> None:
channel = Channel.objects.create(project=self.project)
# Create another channel so we can test that only the first one
@ -261,13 +273,14 @@ class UpdateCheckTestCase(BaseTestCase):
r = self.post(self.check.code, {field: None})
self.assertEqual(r.status_code, 400)
def test_it_validates_cron_expression(self) -> None:
def test_it_validates_schedule(self) -> None:
self.check.kind = "cron"
self.check.schedule = "5 * * * *"
self.check.save()
samples = ["* invalid *", "1,2 61 * * *", "0 0 31 2 *"]
for sample in samples:
cron_samples = ["* invalid *", "1,2 61 * * *", "0 0 31 2 *"]
oncalendar_samples = ["Surprise 12:34", "12:34 Europe/surprise"]
for sample in cron_samples + oncalendar_samples:
r = self.post(self.check.code, {"schedule": sample})
self.assertEqual(r.status_code, 400, f"Did not reject '{sample}'")

View file

@ -5,6 +5,7 @@ import time
from collections.abc import Iterable
from datetime import datetime
from datetime import timedelta as td
from datetime import timezone
from email import message_from_bytes
from typing import Any, Literal
from uuid import UUID
@ -29,6 +30,7 @@ from django.utils.timezone import now
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from oncalendar import OnCalendar, OnCalendarError
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
from pydantic_core import PydanticCustomError
@ -47,6 +49,14 @@ class BadChannelException(Exception):
self.message = message
def guess_kind(schedule: str) -> str:
# If it is a single line with 5 components, it is probably a cron expression:
if "\n" not in schedule.strip() and len(schedule.split()) == 5:
return "cron"
return "oncalendar"
class Spec(BaseModel):
channels: str | None = None
desc: str | None = None
@ -96,20 +106,32 @@ class Spec(BaseModel):
@field_validator("schedule")
@classmethod
def check_schedule(cls, v: str) -> str:
# Does it have 5 components?
if len(v.split()) != 5:
raise PydanticCustomError("cron_syntax", "not a valid cron expression")
try:
# Does cronsim accept the schedule?
it = CronSim(v, datetime(2000, 1, 1))
# Can it calculate the next datetime?
next(it)
except (CronSimError, StopIteration):
raise PydanticCustomError("cron_syntax", "not a valid cron expression")
if guess_kind(v) == "cron":
try:
# Test if cronsim accepts it and can calculate the next datetime
it = CronSim(v, datetime(2000, 1, 1))
next(it)
except (CronSimError, StopIteration):
raise PydanticCustomError("cron_syntax", "not a valid cron expression")
else:
try:
# Test if oncalendar accepts it, and can calculate the next datetime
it = OnCalendar(v, datetime(2000, 1, 1, tzinfo=timezone.utc))
next(it)
except (OnCalendarError, StopIteration):
raise PydanticCustomError("cron_syntax", "not a valid expression")
return v
def kind(self) -> str | None:
if self.schedule:
return guess_kind(self.schedule)
if self.timeout:
return "simple"
return None
CUSTOM_ERRORS = {
"too_long": "%s is too long",
@ -122,7 +144,7 @@ CUSTOM_ERRORS = {
"bool_type": "%s is not a boolean",
"literal_error": "%s has unexpected value",
"list_type": "%s is not an array",
"cron_syntax": "%s is not a valid cron expression",
"cron_syntax": "%s is not a valid cron or OnCalendar expression",
"tz_syntax": "%s is not a valid timezone",
"time_delta_type": "%s is not a number",
}
@ -290,15 +312,16 @@ def _update(check: Check, spec: Spec, v: int) -> None:
check.slug = slugify(spec.name)
need_save = True
if spec.timeout is not None and spec.schedule is None:
kind = spec.kind()
if kind == "simple":
if check.kind != "simple" or check.timeout != spec.timeout:
check.kind = "simple"
check.timeout = spec.timeout
need_save = True
if spec.schedule is not None:
if check.kind != "cron" or check.schedule != spec.schedule:
check.kind = "cron"
if kind in ("cron", "oncalendar"):
if check.kind != kind or check.schedule != spec.schedule:
check.kind = kind
check.schedule = spec.schedule
need_save = True