mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 18:02:41 +02:00
✨ — 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:
parent
0563cf185a
commit
5b999184d0
10 changed files with 150 additions and 113 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
|
@ -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,
|
||||
)
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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")
|
Loading…
Reference in a new issue