— Add a setting to set a reschedule delay if check failed (fix #67)

BREAKING CHANGE: `mo` is no longer accepted for declaring a duration in month in the configuration
You need to use `M`, `month` or `months`

Bonus:  - Allow to choose a frequency smaller than a minute
This commit is contained in:
Luc Didry 2024-11-27 16:26:56 +01:00
parent 0563cf185a
commit 5b999184d0
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
10 changed files with 150 additions and 113 deletions

View file

@ -9,6 +9,10 @@
- ♻️ — Refactor some agent code
- 💄 — Filter form on domains list (#66)
- ✨ — Add "Remember me" checkbox on login (#65)
- ✨ — Add a setting to set a reschedule delay if check failed (#67)
BREAKING CHANGE: `mo` is no longer accepted for declaring a duration in month in the configuration
You need to use `M`, `month` or `months`
- ✨ - Allow to choose a frequency smaller than a minute
## 0.5.0

View file

@ -20,17 +20,25 @@ general:
# Session duration
# Use m for minutes, h for hours, d for days
# w for weeks, mo for months, y for years
# w for weeks, M for months, y for years
# See https://github.com/timwedde/durations_nlp#scales-reference for details
# If not present, default value is "7d"
session_duration: "7d"
# Session opened with "Remember me" checked
# If not present, the "Remember me" feature is not available
# remember_me_duration: "1mo"
# remember_me_duration: "1M"
# Default delay for checks.
# Can be superseeded in domain configuration.
# For ex., to run checks every minute:
frequency: "1m"
# For ex., to run checks every 5 minutes:
frequency: "5m"
# Default re-check delay if a check has failed.
# Can be superseeded in domain configuration.
# If not present, failed checked wont be re-checked (they will be
# run again like if they succeded
# For ex., to re-try a check one minute after a failure:
# recheck_delay: "1m"
# Which way do you want to be warned when a check goes to that severity?
# "local" emits a message in the server log
# Youll need to configure mail, gotify or apprise below to be able to use
@ -179,6 +187,7 @@ websites:
- json-is: '{"foo": "bar", "baz": 42}'
- domain: "https://munin.example.org"
frequency: "20m"
recheck_delay: "5m"
paths:
- path: "/"
checks:

View file

@ -5,8 +5,9 @@ For database models, see argos.server.models.
import json
from typing import Dict, List, Literal, Optional, Tuple
from typing import Dict, List, Literal, Tuple
from durations_nlp import Duration
from pydantic import (
BaseModel,
ConfigDict,
@ -22,7 +23,7 @@ from pydantic.networks import UrlConstraints
from pydantic_core import Url
from typing_extensions import Annotated
from argos.schemas.utils import string_to_duration, Method
from argos.schemas.utils import Method
Severity = Literal["warning", "error", "critical", "unknown"]
Environment = Literal["dev", "test", "production"]
@ -37,7 +38,7 @@ SQLiteDsn = Annotated[
def parse_threshold(value):
"""Parse duration threshold for SSL certificate validity"""
for duration_str, severity in value.items():
days = string_to_duration(duration_str, "days")
days = Duration(duration_str).to_days()
# Return here because it's one-item dicts.
return (days, severity)
@ -115,14 +116,23 @@ class WebsitePath(BaseModel):
class Website(BaseModel):
domain: HttpUrl
frequency: Optional[int] = None
frequency: float | None = None
recheck_delay: float | None = None
paths: List[WebsitePath]
@field_validator("frequency", mode="before")
def parse_frequency(cls, value):
"""Convert the configured frequency to minutes"""
if value:
return string_to_duration(value, "minutes")
return Duration(value).to_minutes()
return None
@field_validator("recheck_delay", mode="before")
def parse_recheck_delay(cls, value):
"""Convert the configured recheck delay to minutes"""
if value:
return Duration(value).to_minutes()
return None
@ -148,7 +158,7 @@ class Mail(BaseModel):
port: PositiveInt = 25
ssl: StrictBool = False
starttls: StrictBool = False
auth: Optional[MailAuth] = None
auth: MailAuth | None = None
addresses: List[EmailStr]
@ -179,31 +189,40 @@ class General(BaseModel):
env: Environment = "production"
cookie_secret: str
session_duration: int = 10080 # 7 days
remember_me_duration: Optional[int] = None
frequency: int
remember_me_duration: int | None = None
frequency: float
recheck_delay: float | None = None
root_path: str = ""
alerts: Alert
mail: Optional[Mail] = None
gotify: Optional[List[GotifyUrl]] = None
apprise: Optional[Dict[str, List[str]]] = None
mail: Mail | None = None
gotify: List[GotifyUrl] | None = None
apprise: Dict[str, List[str]] | None = None
@field_validator("session_duration", mode="before")
def parse_session_duration(cls, value):
"""Convert the configured session duration to minutes"""
return string_to_duration(value, "minutes")
return Duration(value).to_minutes()
@field_validator("remember_me_duration", mode="before")
def parse_remember_me_duration(cls, value):
"""Convert the configured session duration with remember me feature to minutes"""
if value:
return string_to_duration(value, "minutes")
return int(Duration(value).to_minutes())
return None
@field_validator("frequency", mode="before")
def parse_frequency(cls, value):
"""Convert the configured frequency to minutes"""
return string_to_duration(value, "minutes")
return Duration(value).to_minutes()
@field_validator("recheck_delay", mode="before")
def parse_recheck_delay(cls, value):
"""Convert the configured recheck delay to minutes"""
if value:
return Duration(value).to_minutes()
return None
class Config(BaseModel):

View file

@ -4,44 +4,3 @@ from typing import Literal
Method = Literal[
"GET", "HEAD", "POST", "OPTIONS", "CONNECT", "TRACE", "PUT", "PATCH", "DELETE"
]
def string_to_duration(
value: str, target: Literal["days", "hours", "minutes"]
) -> int | float:
"""Convert a string to a number of hours, days or minutes"""
num = int("".join(filter(str.isdigit, value)))
# It's not possible to convert from a smaller unit to a greater one:
# - hours and minutes cannot be converted to days
# - minutes cannot be converted to hours
if (target == "days" and ("h" in value or "m" in value.replace("mo", ""))) or (
target == "hours" and "m" in value.replace("mo", "")
):
msg = (
"Durations cannot be converted from a smaller to a greater unit. "
f"(trying to convert '{value}' to {target})"
)
raise ValueError(msg, value)
# Consider we're converting to minutes, do the eventual multiplication at the end.
if "h" in value:
num = num * 60
elif "d" in value:
num = num * 60 * 24
elif "w" in value:
num = num * 60 * 24 * 7
elif "mo" in value:
num = num * 60 * 24 * 30 # considers 30d in a month
elif "y" in value:
num = num * 60 * 24 * 365 # considers 365d in a year
elif "m" not in value:
raise ValueError("Invalid duration value", value)
if target == "hours":
return num / 60
if target == "days":
return num / 60 / 24
# target == "minutes"
return num

View file

@ -0,0 +1,30 @@
"""Add recheck delay
Revision ID: 127d74c770bb
Revises: dcf73fa19fce
Create Date: 2024-11-27 16:04:58.138768
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "127d74c770bb"
down_revision: Union[str, None] = "dcf73fa19fce"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.add_column(sa.Column("recheck_delay", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("already_retried", sa.Boolean(), nullable=False))
def downgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.drop_column("already_retried")
batch_op.drop_column("recheck_delay")

View file

@ -0,0 +1,38 @@
"""Make frequency a float
Revision ID: a1e98cf72a5c
Revises: 127d74c770bb
Create Date: 2024-11-27 16:10:13.000705
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a1e98cf72a5c"
down_revision: Union[str, None] = "127d74c770bb"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.alter_column(
"frequency",
existing_type=sa.INTEGER(),
type_=sa.Float(),
existing_nullable=False,
)
def downgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.alter_column(
"frequency",
existing_type=sa.Float(),
type_=sa.INTEGER(),
existing_nullable=False,
)

View file

@ -35,7 +35,9 @@ class Task(Base):
domain: Mapped[str] = mapped_column()
check: Mapped[str] = mapped_column()
expected: Mapped[str] = mapped_column()
frequency: Mapped[int] = mapped_column()
frequency: Mapped[float] = mapped_column()
recheck_delay: Mapped[float] = mapped_column(nullable=True)
already_retried: Mapped[bool] = mapped_column(insert_default=False)
method: Mapped[Method] = mapped_column(
Enum(
"GET",
@ -86,7 +88,16 @@ class Task(Base):
now = datetime.now()
self.completed_at = now
if (
self.recheck_delay is not None
and severity != "ok"
and not self.already_retried
):
self.next_run = now + timedelta(minutes=self.recheck_delay)
self.already_retried = True
else:
self.next_run = now + timedelta(minutes=self.frequency)
self.already_retried = False
@property
def last_result(self):

View file

@ -100,6 +100,11 @@ async def has_config_changed(db: Session, config: schemas.Config) -> bool:
same_config = False
conf.val = str(config.general.frequency)
conf.updated_at = datetime.now()
case "general_recheck_delay":
if conf.val != str(config.general.recheck_delay):
same_config = False
conf.val = str(config.general.recheck_delay)
conf.updated_at = datetime.now()
db.commit()
@ -115,8 +120,14 @@ async def has_config_changed(db: Session, config: schemas.Config) -> bool:
val=str(config.general.frequency),
updated_at=datetime.now(),
)
gen_recheck = ConfigCache(
name="general_recheck_delay",
val=str(config.general.recheck_delay),
updated_at=datetime.now(),
)
db.add(web_hash)
db.add(gen_freq)
db.add(gen_recheck)
db.commit()
return True
@ -137,6 +148,7 @@ async def update_from_config(db: Session, config: schemas.Config):
for website in config.websites:
domain = str(website.domain)
frequency = website.frequency or config.general.frequency
recheck_delay = website.recheck_delay or config.general.recheck_delay
for p in website.paths:
url = urljoin(domain, str(p.path))
@ -158,15 +170,18 @@ async def update_from_config(db: Session, config: schemas.Config):
if frequency != existing_task.frequency:
existing_task.frequency = frequency
if recheck_delay != existing_task.recheck_delay:
existing_task.recheck_delay = recheck_delay # type: ignore[assignment]
logger.debug(
"Skipping db task creation for url=%s, "
"method=%s, check_key=%s, expected=%s, "
"frequency=%s.",
"frequency=%s, recheck_delay=%s.",
url,
p.method,
check_key,
expected,
frequency,
recheck_delay,
)
else:
@ -180,6 +195,8 @@ async def update_from_config(db: Session, config: schemas.Config):
check=check_key,
expected=expected,
frequency=frequency,
recheck_delay=recheck_delay,
already_retried=False,
)
logger.debug("Adding a new task in the db: %s", task)
tasks.append(task)

View file

@ -25,6 +25,7 @@ dependencies = [
"apprise>=1.9.0,<2",
"bcrypt>=4.1.3,<5",
"click>=8.1,<9",
"durations-nlp>=1.0.1,<2",
"fastapi>=0.103,<0.104",
"fastapi-login>=1.10.0,<2",
"httpx>=0.27.2,<1",

View file

@ -1,51 +0,0 @@
import pytest
from argos.schemas.utils import string_to_duration
def test_string_to_duration_days():
assert string_to_duration("1d", target="days") == 1
assert string_to_duration("1w", target="days") == 7
assert string_to_duration("3w", target="days") == 21
assert string_to_duration("3mo", target="days") == 90
assert string_to_duration("1y", target="days") == 365
with pytest.raises(ValueError):
string_to_duration("3h", target="days")
with pytest.raises(ValueError):
string_to_duration("1", target="days")
def test_string_to_duration_hours():
assert string_to_duration("1h", target="hours") == 1
assert string_to_duration("1d", target="hours") == 24
assert string_to_duration("1w", target="hours") == 7 * 24
assert string_to_duration("3w", target="hours") == 21 * 24
assert string_to_duration("3mo", target="hours") == 3 * 30 * 24
with pytest.raises(ValueError):
string_to_duration("1", target="hours")
def test_string_to_duration_minutes():
assert string_to_duration("1m", target="minutes") == 1
assert string_to_duration("1h", target="minutes") == 60
assert string_to_duration("1d", target="minutes") == 60 * 24
assert string_to_duration("3mo", target="minutes") == 60 * 24 * 30 * 3
with pytest.raises(ValueError):
string_to_duration("1", target="minutes")
def test_conversion_to_greater_units_throws():
# hours and minutes cannot be converted to days
with pytest.raises(ValueError):
string_to_duration("1h", target="days")
with pytest.raises(ValueError):
string_to_duration("1m", target="days")
# minutes cannot be converted to hours
with pytest.raises(ValueError):
string_to_duration("1m", target="hours")