diff --git a/CHANGELOG.md b/CHANGELOG.md index 6892981..77d3c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/argos/config-example.yaml b/argos/config-example.yaml index 1a69034..8dd096d 100644 --- a/argos/config-example.yaml +++ b/argos/config-example.yaml @@ -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 won’t 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 # You’ll 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: diff --git a/argos/schemas/config.py b/argos/schemas/config.py index 9d70709..e410c4a 100644 --- a/argos/schemas/config.py +++ b/argos/schemas/config.py @@ -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): diff --git a/argos/schemas/utils.py b/argos/schemas/utils.py index fe12087..05d716a 100644 --- a/argos/schemas/utils.py +++ b/argos/schemas/utils.py @@ -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 diff --git a/argos/server/migrations/versions/127d74c770bb_add_recheck_delay.py b/argos/server/migrations/versions/127d74c770bb_add_recheck_delay.py new file mode 100644 index 0000000..3605e8b --- /dev/null +++ b/argos/server/migrations/versions/127d74c770bb_add_recheck_delay.py @@ -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") diff --git a/argos/server/migrations/versions/a1e98cf72a5c_make_frequency_a_float.py b/argos/server/migrations/versions/a1e98cf72a5c_make_frequency_a_float.py new file mode 100644 index 0000000..d0facb7 --- /dev/null +++ b/argos/server/migrations/versions/a1e98cf72a5c_make_frequency_a_float.py @@ -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, + ) diff --git a/argos/server/models.py b/argos/server/models.py index d88fa39..33b05b9 100644 --- a/argos/server/models.py +++ b/argos/server/models.py @@ -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 - self.next_run = now + timedelta(minutes=self.frequency) + 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): diff --git a/argos/server/queries.py b/argos/server/queries.py index 1369e61..94fc0f4 100644 --- a/argos/server/queries.py +++ b/argos/server/queries.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index dbb231e..03e6abe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_schemas_utils.py b/tests/test_schemas_utils.py deleted file mode 100644 index 53cd170..0000000 --- a/tests/test_schemas_utils.py +++ /dev/null @@ -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")