— Allow to specify form data and headers for checks (fix #70)

This commit is contained in:
Luc Didry 2024-12-10 13:46:23 +01:00
parent 9c8be94c20
commit 2ef999fa63
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
11 changed files with 137 additions and 17 deletions

View file

@ -6,6 +6,7 @@
- ⚡ — Mutualize check requests (#68) - ⚡ — Mutualize check requests (#68)
- ✨ — Ability to delay notification after X failures (#71) - ✨ — Ability to delay notification after X failures (#71)
- 🐛 — Fix bug when changing IP version not removing tasks (#72) - 🐛 — Fix bug when changing IP version not removing tasks (#72)
- ✨ — Allow to specify form data and headers for checks (#70)
## 0.6.1 ## 0.6.1

View file

@ -6,6 +6,7 @@ import asyncio
import json import json
import logging import logging
import socket import socket
from hashlib import md5
from time import sleep from time import sleep
from typing import List from typing import List
@ -57,7 +58,7 @@ class ArgosAgent: # pylint: disable-msg=too-many-instance-attributes
self._http_client = httpx.AsyncClient(headers=auth_header) self._http_client = httpx.AsyncClient(headers=auth_header)
ua_header = { ua_header = {
"User-Agent": f"Prout Argos Panoptes {VERSION} " "User-Agent": f"Argos Panoptes {VERSION} "
"(about: https://argos-monitoring.framasoft.org/)", "(about: https://argos-monitoring.framasoft.org/)",
} }
self._http_client_v4 = httpx.AsyncClient( self._http_client_v4 = httpx.AsyncClient(
@ -77,24 +78,59 @@ class ArgosAgent: # pylint: disable-msg=too-many-instance-attributes
await asyncio.sleep(self.wait_time) await asyncio.sleep(self.wait_time)
async def _do_request(self, group: str, details: dict): async def _do_request(self, group: str, details: dict):
try: headers = {}
if details["request_data"] is not None:
request_data = json.loads(details["request_data"])
if request_data["headers"] is not None:
headers = request_data["headers"]
if details["ip_version"] == "4": if details["ip_version"] == "4":
response = await self._http_client_v4.request( # type: ignore[union-attr] http_client = self._http_client_v4
method=details["method"], url=details["url"], timeout=60 else:
http_client = self._http_client_v6
try:
if details["request_data"] is None or request_data["data"] is None:
response = await http_client.request( # type: ignore[union-attr]
method=details["method"],
url=details["url"],
headers=headers,
timeout=60,
)
elif request_data["json"]:
response = await http_client.request( # type: ignore[union-attr]
method=details["method"],
url=details["url"],
headers=headers,
json=request_data["data"],
timeout=60,
) )
else: else:
response = await self._http_client_v6.request( # type: ignore[union-attr] response = await http_client.request( # type: ignore[union-attr]
method=details["method"], url=details["url"], timeout=60 method=details["method"],
url=details["url"],
headers=headers,
data=request_data["data"],
timeout=60,
) )
except httpx.ReadError: except httpx.ReadError:
sleep(1) sleep(1)
if details["ip_version"] == "4": if details["request_data"] is None or request_data["data"] is None:
response = await self._http_client_v4.request( # type: ignore[union-attr] response = await http_client.request( # type: ignore[union-attr]
method=details["method"], url=details["url"], timeout=60 method=details["method"], url=details["url"], timeout=60
) )
elif request_data["json"]:
response = await http_client.request( # type: ignore[union-attr]
method=details["method"],
url=details["url"],
json=request_data["data"],
timeout=60,
)
else: else:
response = await self._http_client_v6.request( # type: ignore[union-attr] response = await http_client.request( # type: ignore[union-attr]
method=details["method"], url=details["url"], timeout=60 method=details["method"],
url=details["url"],
data=request_data["data"],
timeout=60,
) )
self._res_cache[group] = response self._res_cache[group] = response
@ -135,14 +171,21 @@ class ArgosAgent: # pylint: disable-msg=too-many-instance-attributes
group = task.task_group group = task.task_group
if task.check == "http-to-https": if task.check == "http-to-https":
data = task.request_data
if data is None:
data = ""
url = str(httpx.URL(task.url).copy_with(scheme="http")) url = str(httpx.URL(task.url).copy_with(scheme="http"))
group = f"{task.method}-{task.ip_version}-{url}" group = (
f"{task.method}-{task.ip_version}-{url}-"
f"{md5(data.encode()).hexdigest()}"
)
_task["task_group"] = group _task["task_group"] = group
req_groups[group] = { req_groups[group] = {
"url": url, "url": url,
"ip_version": task.ip_version, "ip_version": task.ip_version,
"method": task.method, "method": task.method,
"request_data": task.request_data,
} }
requests = [] requests = []

View file

@ -190,6 +190,17 @@ websites:
- 302 - 302
- 307 - 307
- path: "/admin/" - path: "/admin/"
methode: "POST"
# Send form data in the request
request_data:
data:
login: "admin"
password: "my-password"
# To send data as JSON (optional, default is false):
is_json: true
# To send additional headers
headers:
Authorization: "Bearer foo-bar-baz"
checks: checks:
# Check that the return HTTP status is one of those # Check that the return HTTP status is one of those
# Similar to status-is, verify that you dont mistyped it! # Similar to status-is, verify that you dont mistyped it!

View file

@ -5,7 +5,7 @@ For database models, see argos.server.models.
import json import json
from typing import Dict, List, Literal, Tuple from typing import Any, Dict, List, Literal, Tuple
from durations_nlp import Duration from durations_nlp import Duration
from pydantic import ( from pydantic import (
@ -18,7 +18,7 @@ from pydantic import (
PositiveInt, PositiveInt,
field_validator, field_validator,
) )
from pydantic.functional_validators import BeforeValidator from pydantic.functional_validators import AfterValidator, BeforeValidator
from pydantic.networks import UrlConstraints from pydantic.networks import UrlConstraints
from pydantic_core import Url from pydantic_core import Url
from typing_extensions import Annotated from typing_extensions import Annotated
@ -104,9 +104,26 @@ def parse_checks(value):
return (name, expected) return (name, expected)
def parse_request_data(value):
"""Turn form or JSON data into JSON string"""
return json.dumps(
{"data": value.data, "json": value.is_json, "headers": value.headers}
)
class RequestData(BaseModel):
data: Any = None
is_json: bool = False
headers: Dict[str, str] | None = None
class WebsitePath(BaseModel): class WebsitePath(BaseModel):
path: str path: str
method: Method = "GET" method: Method = "GET"
request_data: Annotated[
RequestData, AfterValidator(parse_request_data)
] | None = None
checks: List[ checks: List[
Annotated[ Annotated[
Tuple[str, str], Tuple[str, str],

View file

@ -22,6 +22,7 @@ class Task(BaseModel):
ip_version: IPVersion ip_version: IPVersion
check: str check: str
method: Method method: Method
request_data: str | None
expected: str expected: str
task_group: str task_group: str
retry_before_notification: int retry_before_notification: int

View file

@ -0,0 +1,28 @@
"""Add request data to tasks
Revision ID: 31255a412d63
Revises: 80a29f64f91c
Create Date: 2024-12-09 16:40:20.926138
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "31255a412d63"
down_revision: Union[str, None] = "80a29f64f91c"
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("request_data", sa.String(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.drop_column("request_data")

View file

@ -1,6 +1,7 @@
"""Database models""" """Database models"""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from hashlib import md5
from typing import List, Literal from typing import List, Literal
from sqlalchemy import ( from sqlalchemy import (
@ -17,10 +18,14 @@ from argos.schemas.utils import IPVersion, Method
def compute_task_group(context) -> str: def compute_task_group(context) -> str:
data = context.current_parameters["request_data"]
if data is None:
data = ""
return ( return (
f"{context.current_parameters['method']}-" f"{context.current_parameters['method']}-"
f"{context.current_parameters['ip_version']}-" f"{context.current_parameters['ip_version']}-"
f"{context.current_parameters['url']}" f"{context.current_parameters['url']}-"
f"{md5(data.encode()).hexdigest()}"
) )
@ -67,6 +72,7 @@ class Task(Base):
), ),
insert_default="GET", insert_default="GET",
) )
request_data: Mapped[str] = mapped_column(nullable=True)
# Orchestration-related # Orchestration-related
selected_by: Mapped[str] = mapped_column(nullable=True) selected_by: Mapped[str] = mapped_column(nullable=True)

View file

@ -256,6 +256,7 @@ async def update_from_config(db: Session, config: schemas.Config): # pylint: di
.filter( .filter(
Task.url == url, Task.url == url,
Task.method == p.method, Task.method == p.method,
Task.request_data == p.request_data,
Task.check == check_key, Task.check == check_key,
Task.expected == expected, Task.expected == expected,
Task.ip_version == ip_version, Task.ip_version == ip_version,
@ -300,7 +301,14 @@ async def update_from_config(db: Session, config: schemas.Config): # pylint: di
) )
else: else:
properties = (url, p.method, check_key, expected, ip_version) properties = (
url,
p.method,
check_key,
expected,
ip_version,
p.request_data,
)
if properties not in unique_properties: if properties not in unique_properties:
unique_properties.append(properties) unique_properties.append(properties)
task = Task( task = Task(
@ -308,6 +316,7 @@ async def update_from_config(db: Session, config: schemas.Config): # pylint: di
url=url, url=url,
ip_version=ip_version, ip_version=ip_version,
method=p.method, method=p.method,
request_data=p.request_data,
check=check_key, check=check_key,
expected=expected, expected=expected,
frequency=frequency, frequency=frequency,

View file

@ -1,6 +1,8 @@
---
general: general:
db: db:
# The database URL, as defined in SQLAlchemy docs : https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls # The database URL, as defined in SQLAlchemy docs:
# https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
url: "sqlite:////tmp/test-argos.db" url: "sqlite:////tmp/test-argos.db"
env: test env: test
cookie_secret: "foo-bar-baz" cookie_secret: "foo-bar-baz"

View file

@ -37,6 +37,7 @@ def ssl_task(now):
domain="https://example.org", domain="https://example.org",
ip_version="6", ip_version="6",
method="GET", method="GET",
request_data=None,
task_group="GET-6-https://example.org", task_group="GET-6-https://example.org",
check="ssl-certificate-expiration", check="ssl-certificate-expiration",
retry_before_notification=0, retry_before_notification=0,

View file

@ -1,3 +1,4 @@
---
- domain: "https://mypads.framapad.org" - domain: "https://mypads.framapad.org"
paths: paths:
- path: "/mypads/" - path: "/mypads/"