🔀 Merge remote-tracking branch 'origin/develop'

This commit is contained in:
Luc Didry 2024-09-02 14:34:18 +02:00
commit dec6c72238
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
28 changed files with 564 additions and 143 deletions

View file

@ -37,6 +37,12 @@ djlint:
script: script:
- make djlint - make djlint
mypy:
<<: *pull_cache
stage: test
script:
- make mypy
pylint: pylint:
<<: *pull_cache <<: *pull_cache
stage: test stage: test
@ -61,6 +67,10 @@ release_job:
release: # See https://docs.gitlab.com/ee/ci/yaml/#release for available properties release: # See https://docs.gitlab.com/ee/ci/yaml/#release for available properties
tag_name: '$CI_COMMIT_TAG' tag_name: '$CI_COMMIT_TAG'
description: './release.md' description: './release.md'
assets:
links:
- name: 'PyPI page'
url: 'https://pypi.org/project/argos-monitoring/$CI_COMMIT_TAG/'
pages: pages:
<<: *pull_cache <<: *pull_cache

View file

@ -2,6 +2,16 @@
## [Unreleased] ## [Unreleased]
- 🩹 — Fix release documentation
- ✅ — Add mypy test
- ✨ — Add new check type: status-in
- 🩹 — Close menu after rescheduling non-ok checks (#55)
- ✨ — Add new check types: headers-contain and headers-have (#56)
- ✨ — Add command to test email configuration (!66)
- 💄 — Enhance the mobile view (!67)
- ✨ — Allow to run Argos in a subfolder (i.e. not on /) (#59)
- ✨ — Add new check types: json-contains, json-has and json-is (#57)
## 0.2.2 ## 0.2.2
Date: 2024-07-04 Date: 2024-07-04

View file

@ -25,10 +25,12 @@ ruff: venv
ruff-format: venv ruff-format: venv
venv/bin/ruff format . venv/bin/ruff format .
djlint: venv ## Format the templates djlint: venv ## Format the templates
venv/bin/djlint --ignore=H030,H031,H006 --profile jinja --lint argos/server/templates/*html venv/bin/djlint --ignore=H006 --profile jinja --lint argos/server/templates/*html
pylint: venv ## Runs pylint on the code pylint: venv ## Runs pylint on the code
venv/bin/pylint argos venv/bin/pylint argos
lint: djlint pylint ruff mypy: venv
venv/bin/mypy argos tests
lint: djlint pylint mypy ruff
help: help:
@python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) @python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)

View file

@ -54,12 +54,12 @@ class ArgosAgent:
while "forever": while "forever":
retry_now = await self._get_and_complete_tasks() retry_now = await self._get_and_complete_tasks()
if not retry_now: if not retry_now:
logger.error("Waiting %i seconds before next retry", self.wait_time) logger.info("Waiting %i seconds before next retry", self.wait_time)
await asyncio.sleep(self.wait_time) await asyncio.sleep(self.wait_time)
async def _complete_task(self, task: dict) -> dict: async def _complete_task(self, _task: dict) -> AgentResult:
try: try:
task = Task(**task) task = Task(**_task)
check_class = get_registered_check(task.check) check_class = get_registered_check(task.check)
check = check_class(self._http_client, task) check = check_class(self._http_client, task)
result = await check.run() result = await check.run()
@ -69,7 +69,7 @@ class ArgosAgent:
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-except
status = "error" status = "error"
context = SerializableException.from_exception(err) context = SerializableException.from_exception(err)
msg = f"An exception occured when running {task}. {err.__class__.__name__} : {err}" msg = f"An exception occured when running {_task}. {err.__class__.__name__} : {err}"
logger.error(msg) logger.error(msg)
return AgentResult(task_id=task.id, status=status, context=context) return AgentResult(task_id=task.id, status=status, context=context)
@ -94,7 +94,7 @@ class ArgosAgent:
await self._post_results(results) await self._post_results(results)
return True return True
logger.error("Got no tasks from the server.") logger.info("Got no tasks from the server.")
return False return False
logger.error("Failed to fetch tasks: %s", response.read()) logger.error("Failed to fetch tasks: %s", response.read())
@ -102,12 +102,19 @@ class ArgosAgent:
async def _post_results(self, results: List[AgentResult]): async def _post_results(self, results: List[AgentResult]):
data = [r.model_dump() for r in results] data = [r.model_dump() for r in results]
response = await self._http_client.post( if self._http_client is not None:
f"{self.server}/api/results", params={"agent_id": self.agent_id}, json=data response = await self._http_client.post(
) f"{self.server}/api/results",
params={"agent_id": self.agent_id},
json=data,
)
if response.status_code == httpx.codes.CREATED: if response.status_code == httpx.codes.CREATED:
logger.error("Successfully posted results %s", json.dumps(response.json())) logger.info(
else: "Successfully posted results %s", json.dumps(response.json())
logger.error("Failed to post results: %s", response.read()) )
return response else:
logger.error("Failed to post results: %s", response.read())
return response
logger.error("self._http_client is None")

View file

@ -1,7 +1,7 @@
"""Various base classes for checks""" """Various base classes for checks"""
from dataclasses import dataclass from dataclasses import dataclass
from typing import Type, Union from typing import Type
import httpx import httpx
from pydantic import BaseModel from pydantic import BaseModel
@ -71,7 +71,7 @@ class InvalidResponse(Exception):
class BaseCheck: class BaseCheck:
config: str config: str
expected_cls: Union[None, Type[BaseExpectedValue]] = None expected_cls: None | Type[BaseExpectedValue] = None
_registry = [] # type: ignore[var-annotated] _registry = [] # type: ignore[var-annotated]

View file

@ -1,7 +1,10 @@
"""Define the available checks""" """Define the available checks"""
import json
from datetime import datetime from datetime import datetime
from jsonpointer import resolve_pointer, JsonPointerException
from argos.checks.base import ( from argos.checks.base import (
BaseCheck, BaseCheck,
ExpectedIntValue, ExpectedIntValue,
@ -31,6 +34,82 @@ class HTTPStatus(BaseCheck):
) )
class HTTPStatusIn(BaseCheck):
"""Checks that the HTTP status code is in the list of expected values."""
config = "status-in"
expected_cls = ExpectedStringValue
async def run(self) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
return self.response(
status=response.status_code in json.loads(self.expected),
expected=self.expected,
retrieved=response.status_code,
)
class HTTPHeadersContain(BaseCheck):
"""Checks that response headers contains the expected headers
(without checking their values)"""
config = "headers-contain"
expected_cls = ExpectedStringValue
async def run(self) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
status = True
for header in json.loads(self.expected):
if header not in response.headers:
status = False
break
return self.response(
status=status,
expected=self.expected,
retrieved=json.dumps(list(dict(response.headers).keys())),
)
class HTTPHeadersHave(BaseCheck):
"""Checks that response headers contains the expected headers and values"""
config = "headers-have"
expected_cls = ExpectedStringValue
async def run(self) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
status = True
for header, value in json.loads(self.expected).items():
if header not in response.headers:
status = False
break
if response.headers[header] != value:
status = False
break
return self.response(
status=status,
expected=self.expected,
retrieved=json.dumps(dict(response.headers)),
)
class HTTPBodyContains(BaseCheck): class HTTPBodyContains(BaseCheck):
"""Checks that the HTTP body contains the expected string.""" """Checks that the HTTP body contains the expected string."""
@ -44,6 +123,94 @@ class HTTPBodyContains(BaseCheck):
return self.response(status=self.expected in response.text) return self.response(status=self.expected in response.text)
class HTTPJsonContains(BaseCheck):
"""Checks that JSON response contains the expected structure
(without checking the value)"""
config = "json-contains"
expected_cls = ExpectedStringValue
async def run(self) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
obj = response.json()
status = True
for pointer in json.loads(self.expected):
try:
resolve_pointer(obj, pointer)
except JsonPointerException:
status = False
break
return self.response(
status=status,
expected=self.expected,
retrieved=json.dumps(obj),
)
class HTTPJsonHas(BaseCheck):
"""Checks that JSON response contains the expected structure and values"""
config = "json-has"
expected_cls = ExpectedStringValue
async def run(self) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
obj = response.json()
status = True
for pointer, exp_value in json.loads(self.expected).items():
try:
value = resolve_pointer(obj, pointer)
if value != exp_value:
status = False
break
except JsonPointerException:
status = False
break
return self.response(
status=status,
expected=self.expected,
retrieved=json.dumps(obj),
)
class HTTPJsonIs(BaseCheck):
"""Checks that JSON response is the exact expected JSON object"""
config = "json-is"
expected_cls = ExpectedStringValue
async def run(self) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
obj = response.json()
status = response.json() == json.loads(self.expected)
return self.response(
status=status,
expected=self.expected,
retrieved=json.dumps(obj),
)
class SSLCertificateExpiration(BaseCheck): class SSLCertificateExpiration(BaseCheck):
"""Checks that the SSL certificate will not expire soon.""" """Checks that the SSL certificate will not expire soon."""

View file

@ -10,8 +10,7 @@ import uvicorn
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from argos import logging from argos import VERSION, logging
from argos import VERSION
from argos.agent import ArgosAgent from argos.agent import ArgosAgent
@ -305,9 +304,10 @@ async def add(config, name, password):
os.environ["ARGOS_YAML_FILE"] = config os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files. # The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from passlib.context import CryptContext from passlib.context import CryptContext
from argos.server import queries
db = await get_db() db = await get_db()
_user = await queries.get_user(db, name) _user = await queries.get_user(db, name)
if _user is not None: if _user is not None:
@ -339,9 +339,10 @@ async def change_password(config, name, password):
os.environ["ARGOS_YAML_FILE"] = config os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files. # The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from passlib.context import CryptContext from passlib.context import CryptContext
from argos.server import queries
db = await get_db() db = await get_db()
_user = await queries.get_user(db, name) _user = await queries.get_user(db, name)
if _user is None: if _user is None:
@ -374,9 +375,10 @@ async def verify_password(config, name, password):
os.environ["ARGOS_YAML_FILE"] = config os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files. # The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from passlib.context import CryptContext from passlib.context import CryptContext
from argos.server import queries
db = await get_db() db = await get_db()
_user = await queries.get_user(db, name) _user = await queries.get_user(db, name)
if _user is None: if _user is None:
@ -548,5 +550,69 @@ async def generate_config():
print(f.read()) print(f.read())
@server.command()
@click.option(
"--config",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@click.option("--domain", help="Domain for the notification", default="example.org")
@click.option("--severity", help="Severity", default="CRITICAL")
@coroutine
async def test_mail(config, domain, severity):
"""Send a test email"""
os.environ["ARGOS_YAML_FILE"] = config
from datetime import datetime
from argos.logging import set_log_level
from argos.server.alerting import notify_by_mail
from argos.server.main import read_config
from argos.server.models import Result, Task
conf = read_config(config)
if not conf.general.mail:
click.echo("Mail is not configured, cannot test", err=True)
sysexit(1)
else:
now = datetime.now()
task = Task(
url=f"https://{domain}",
domain=domain,
check="body-contains",
expected="foo",
frequency=1,
selected_by="test",
selected_at=now,
)
result = Result(
submitted_at=now,
status="success",
context={"foo": "bar"},
task=task,
agent_id="test",
severity="ok",
)
class _FalseRequest:
def url_for(*args, **kwargs):
return "/url"
set_log_level("debug")
notify_by_mail(
result,
task,
severity="SEVERITY",
old_severity="OLD SEVERITY",
config=conf.general.mail,
request=_FalseRequest(),
)
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

View file

@ -1,10 +1,14 @@
---
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
# Example for SQLite: "sqlite:////tmp/argos.db" # Example for SQLite: "sqlite:////tmp/argos.db"
url: "postgresql://argos:argos@localhost/argos" url: "postgresql://argos:argos@localhost/argos"
# You configure the size of the database pool of connection, and the max overflow (until when new connections are accepted ?) # You configure the size of the database pool of connection, and
# See https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.params.pool_size for details # the max overflow (until when new connections are accepted ?)
# For details, see
# https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.params.pool_size
pool_size: 10 pool_size: 10
max_overflow: 20 max_overflow: 20
# Can be "production", "dev", "test". # Can be "production", "dev", "test".
@ -29,6 +33,11 @@ general:
- local - local
unknown: unknown:
- local - local
# Argos root path
# If not present, default value is ""
# Set it to /foo if you want to use argos at /foo/ instead of /
# on your web server
# root_path: "/foo"
# Mail configuration is quite straight-forward # Mail configuration is quite straight-forward
# mail: # mail:
# mailfrom: no-reply@example.org # mailfrom: no-reply@example.org
@ -71,12 +80,45 @@ websites:
paths: paths:
- path: "/mypads/" - path: "/mypads/"
checks: checks:
# Check that the returned HTTP status is 200
- status-is: 200 - status-is: 200
# Check that the response contains this string
- body-contains: '<div id= "mypads"></div>' - body-contains: '<div id= "mypads"></div>'
# Check that the SSL certificate is no older than ssl.thresholds
- ssl-certificate-expiration: "on-check" - ssl-certificate-expiration: "on-check"
# Check that the response contains this headers
# The comparison is case insensitive
- headers-contain:
- "content-encoding"
- "content-type"
- path: "/admin/" - path: "/admin/"
checks: checks:
- status-is: 401 # Check that the return HTTP status is one of those
# Similar to status-is, verify that you dont mistyped it!
- status-in:
- 401
- 301
# Check that the response contains this headers and values
# Its VERY important to respect the 4 spaces indentation here!
# The name of the headers is case insensitive
- headers-have:
content-encoding: "gzip"
content-type: "text/html"
- path: "/my-stats.json"
checks:
# Check that JSON response contains the expected structure
- json-contains:
- /foo/bar/0
- /foo/bar/1
- /timestamp
# Check that JSON response contains the expected structure and values
# Its VERY important to respect the 4 spaces indentation here!
- json-has:
/maintenance: false
/productname: "Nextcloud"
# Check that JSON response is the exact expected JSON object
# The order of the items in the object does not matter.
- json-is: '{"foo": "bar", "baz": 42}'
- domain: "https://munin.example.org" - domain: "https://munin.example.org"
frequency: "20m" frequency: "20m"
paths: paths:

View file

@ -2,6 +2,9 @@
For database models, see argos.server.models. For database models, see argos.server.models.
""" """
import json
from typing import Dict, List, Literal, Optional, Tuple from typing import Dict, List, Literal, Optional, Tuple
from pydantic import ( from pydantic import (
@ -78,6 +81,10 @@ def parse_checks(value):
raise ValueError(msg) raise ValueError(msg)
if isinstance(expected, int): if isinstance(expected, int):
expected = str(expected) expected = str(expected)
if isinstance(expected, list):
expected = json.dumps(expected)
if isinstance(expected, dict):
expected = json.dumps(expected)
return (name, expected) return (name, expected)
@ -157,6 +164,7 @@ class General(BaseModel):
frequency: int frequency: int
db: DbSettings db: DbSettings
env: Environment = "production" env: Environment = "production"
root_path: str = ""
alerts: Alert alerts: Alert
mail: Optional[Mail] = None mail: Optional[Mail] = None
gotify: Optional[List[GotifyUrl]] = None gotify: Optional[List[GotifyUrl]] = None

View file

@ -1,9 +1,9 @@
from typing import Literal, Union from typing import Literal
def string_to_duration( def string_to_duration(
value: str, target: Literal["days", "hours", "minutes"] value: str, target: Literal["days", "hours", "minutes"]
) -> Union[int, float]: ) -> int | float:
"""Convert a string to a number of hours, days or minutes""" """Convert a string to a number of hours, days or minutes"""
num = int("".join(filter(str.isdigit, value))) num = int("".join(filter(str.isdigit, value)))

View file

@ -18,11 +18,19 @@ from argos.server.settings import read_yaml_config
def get_application() -> FastAPI: def get_application() -> FastAPI:
"""Spawn Argos FastAPI server""" """Spawn Argos FastAPI server"""
appli = FastAPI(lifespan=lifespan)
config_file = os.environ["ARGOS_YAML_FILE"] config_file = os.environ["ARGOS_YAML_FILE"]
config = read_config(config_file) config = read_config(config_file)
root_path = config.general.root_path
if root_path != "":
logger.info("Root path for Argos: %s", root_path)
if root_path.endswith("/"):
root_path = root_path[:-1]
logger.info("Fixed root path for Argos: %s", root_path)
appli = FastAPI(lifespan=lifespan, root_path=root_path)
# Config is the argos config object (built from yaml) # Config is the argos config object (built from yaml)
appli.state.config = config appli.state.config = config
appli.add_exception_handler(NotAuthenticatedException, auth_exception_handler) appli.add_exception_handler(NotAuthenticatedException, auth_exception_handler)

View file

@ -51,7 +51,7 @@ async def list_users(db: Session):
return db.query(User).order_by(asc(User.username)) return db.query(User).order_by(asc(User.username))
async def get_task(db: Session, task_id: int) -> Task: async def get_task(db: Session, task_id: int) -> None | Task:
return db.get(Task, task_id) return db.get(Task, task_id)
@ -71,9 +71,9 @@ async def count_tasks(db: Session, selected: None | bool = None):
query = db.query(Task) query = db.query(Task)
if selected is not None: if selected is not None:
if selected: if selected:
query = query.filter(Task.selected_by is not None) query = query.filter(Task.selected_by is not None) # type: ignore[arg-type]
else: else:
query = query.filter(Task.selected_by is None) query = query.filter(Task.selected_by is None) # type: ignore[arg-type]
return query.count() return query.count()
@ -98,7 +98,7 @@ async def has_config_changed(db: Session, config: schemas.Config) -> bool:
case "general_frequency": case "general_frequency":
if conf.val != str(config.general.frequency): if conf.val != str(config.general.frequency):
same_config = False same_config = False
conf.val = config.general.frequency conf.val = str(config.general.frequency)
conf.updated_at = datetime.now() conf.updated_at = datetime.now()
db.commit() db.commit()
@ -208,7 +208,7 @@ async def get_severity_counts(db: Session) -> dict:
# Execute the query and fetch the results # Execute the query and fetch the results
task_counts_by_severity = query.all() task_counts_by_severity = query.all()
counts_dict = dict(task_counts_by_severity) counts_dict = dict(task_counts_by_severity) # type: ignore[var-annotated,arg-type]
for key in ("ok", "warning", "critical", "unknown"): for key in ("ok", "warning", "critical", "unknown"):
counts_dict.setdefault(key, 0) counts_dict.setdefault(key, 0)
return counts_dict return counts_dict

View file

@ -1,5 +1,5 @@
"""Web interface for machines""" """Web interface for machines"""
from typing import List, Union from typing import List
from fastapi import APIRouter, BackgroundTasks, Depends, Request from fastapi import APIRouter, BackgroundTasks, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -18,10 +18,13 @@ async def read_tasks(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
limit: int = 10, limit: int = 10,
agent_id: Union[None, str] = None, agent_id: None | str = None,
): ):
"""Return a list of tasks to execute""" """Return a list of tasks to execute"""
agent_id = agent_id or request.client.host host = ""
if request.client is not None:
host = request.client.host
agent_id = agent_id or host
tasks = await queries.list_tasks(db, agent_id=agent_id, limit=limit) tasks = await queries.list_tasks(db, agent_id=agent_id, limit=limit)
return tasks return tasks
@ -33,7 +36,7 @@ async def create_results(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
config: Config = Depends(get_config), config: Config = Depends(get_config),
agent_id: Union[None, str] = None, agent_id: None | str = None,
): ):
"""Get the results from the agents and store them locally. """Get the results from the agents and store them locally.
@ -42,7 +45,10 @@ async def create_results(
- If it's an error, determine its severity ; - If it's an error, determine its severity ;
- Trigger the reporting calls - Trigger the reporting calls
""" """
agent_id = agent_id or request.client.host host = ""
if request.client is not None:
host = request.client.host
agent_id = agent_id or host
db_results = [] db_results = []
for agent_result in results: for agent_result in results:
# XXX Maybe offload this to a queue. # XXX Maybe offload this to a queue.

View file

@ -1,5 +1,6 @@
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi_login import LoginManager
auth_scheme = HTTPBearer() auth_scheme = HTTPBearer()
@ -16,7 +17,7 @@ def get_config(request: Request):
return request.app.state.config return request.app.state.config
async def get_manager(request: Request): async def get_manager(request: Request) -> LoginManager:
return await request.app.state.manager(request) return await request.app.state.manager(request)

View file

@ -125,7 +125,7 @@ async def get_domains_view(
tasks = db.query(Task).all() tasks = db.query(Task).all()
domains_severities = defaultdict(list) domains_severities = defaultdict(list)
domains_last_checks = defaultdict(list) domains_last_checks = defaultdict(list) # type: ignore[var-annotated]
for task in tasks: for task in tasks:
domain = urlparse(task.url).netloc domain = urlparse(task.url).netloc
@ -210,7 +210,9 @@ async def get_task_results_view(
.all() .all()
) )
task = db.query(Task).get(task_id) task = db.query(Task).get(task_id)
description = task.get_check().get_description(config) description = ""
if task is not None:
description = task.get_check().get_description(config)
return templates.TemplateResponse( return templates.TemplateResponse(
"results.html", "results.html",
{ {
@ -251,8 +253,8 @@ async def set_refresh_cookies_view(
request.url_for("get_severity_counts_view"), request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
) )
response.set_cookie(key="auto_refresh_enabled", value=auto_refresh_enabled) response.set_cookie(key="auto_refresh_enabled", value=str(auto_refresh_enabled))
response.set_cookie( response.set_cookie(
key="auto_refresh_seconds", value=max(5, int(auto_refresh_seconds)) key="auto_refresh_seconds", value=str(max(5, int(auto_refresh_seconds)))
) )
return response return response

View file

@ -7,12 +7,12 @@ from yamlinclude import YamlIncludeConstructor
from argos.schemas.config import Config from argos.schemas.config import Config
def read_yaml_config(filename): def read_yaml_config(filename: str) -> Config:
parsed = _load_yaml(filename) parsed = _load_yaml(filename)
return Config(**parsed) return Config(**parsed)
def _load_yaml(filename): def _load_yaml(filename: str):
base_dir = Path(filename).resolve().parent base_dir = Path(filename).resolve().parent
YamlIncludeConstructor.add_to_loader_class( YamlIncludeConstructor.add_to_loader_class(
loader_class=yaml.FullLoader, base_dir=str(base_dir) loader_class=yaml.FullLoader, base_dir=str(base_dir)

View file

@ -4,10 +4,6 @@ code {
white-space: pre-wrap; white-space: pre-wrap;
} }
body > header,
body > main {
padding: 0 !important;
}
#title { #title {
margin-bottom: 0; margin-bottom: 0;
} }
@ -53,3 +49,7 @@ label[for="select-status"] {
#refresh-delay { #refresh-delay {
max-width: 120px; max-width: 120px;
} }
/* Remove chevron on menu */
#nav-menu summary::after {
background-image: none !important;
}

View file

@ -3,6 +3,10 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Argos</title> <title>Argos</title>
<meta name="description"
content="Argos monitoring">
<meta name="keywords"
content="argos, monitoring">
<link rel="shortcut icon" <link rel="shortcut icon"
href="{{ url_for('static', path='/logo.png') }}"> href="{{ url_for('static', path='/logo.png') }}">
<meta name="viewport" <meta name="viewport"
@ -12,14 +16,14 @@
{% if auto_refresh_enabled %} {% if auto_refresh_enabled %}
<meta http-equiv="refresh" <meta http-equiv="refresh"
content="{{ auto_refresh_seconds }}"> content="{{ auto_refresh_seconds }}">
{% endif %} {%- endif %}
<link rel="stylesheet" <link rel="stylesheet"
href="{{ url_for('static', path='/styles.css') }}"> href="{{ url_for('static', path='/styles.css') }}">
</head> </head>
<body> <body>
<header class="container"> <header class="container">
<nav> <nav>
<a href="{{ url_for('get_severity_counts_view') }}"> <a href="{{ url_for("get_severity_counts_view") }}">
<ul> <ul>
<li> <li>
<img src="{{ url_for('static', path='/logo-64.png') }}" <img src="{{ url_for('static', path='/logo-64.png') }}"
@ -34,48 +38,50 @@
</a> </a>
{% if request.url.remove_query_params('msg') != url_for('login_view') %} {% if request.url.remove_query_params('msg') != url_for('login_view') %}
<ul> <ul>
<details class="dropdown"> <li>
<summary autofocus>Menu</summary> <details id="nav-menu" class="dropdown">
<ul> <summary autofocus>Menu</summary>
<li> <ul dir="rtl">
<a href="{{ url_for('get_severity_counts_view') }}" <li>
class="outline {{ 'contrast' if request.url == url_for('get_severity_counts_view') }}" <a href="{{ url_for('get_severity_counts_view') }}"
role="button"> class="outline {{ 'contrast' if request.url == url_for('get_severity_counts_view') }}"
Dashboard role="button">
</a> Dashboard
</li> </a>
<li> </li>
<a href="{{ url_for('get_domains_view') }}" <li>
class="outline {{ 'contrast' if request.url == url_for('get_domains_view') }}" <a href="{{ url_for('get_domains_view') }}"
role="button"> class="outline {{ 'contrast' if request.url == url_for('get_domains_view') }}"
Domains role="button">
</a> Domains
</li> </a>
<li> </li>
<a href="{{ url_for('get_agents_view') }}" <li>
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}" <a href="{{ url_for('get_agents_view') }}"
role="button"> class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}"
Agents role="button">
</a> Agents
</li> </a>
<li> </li>
<a href="#" <li>
id="reschedule-all" <a href="#"
class="outline" id="reschedule-all"
title="Reschedule non-ok checks as soon as possible" class="outline"
role="button"> title="Reschedule non-ok checks as soon as possible"
Reschedule non-ok checks role="button">
</a> Reschedule non-ok checks
</li> </a>
<li> </li>
<a href="{{ url_for('logout_view') }}" <li>
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}" <a href="{{ url_for('logout_view') }}"
role="button"> class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}"
Logout role="button">
</a> Logout
</li> </a>
</ul> </li>
</details> </ul>
</details>
</li>
</ul> </ul>
{% endif %} {% endif %}
</nav> </nav>
@ -97,25 +103,28 @@
(<a href="https://framagit.org/framasoft/framaspace/argos">sources</a>) (<a href="https://framagit.org/framasoft/framaspace/argos">sources</a>)
<br> <br>
API documentation: API documentation:
<a href="{{ url_for('get_severity_counts_view') }}docs">Swagger</a> <a href="{{ url_for("get_severity_counts_view") }}docs">Swagger</a>
or or
<a href="{{ url_for('get_severity_counts_view') }}redoc">Redoc</a> <a href="{{ url_for("get_severity_counts_view") }}redoc">Redoc</a>
</footer> </footer>
<script> {% if request.url.remove_query_params('msg') != url_for('login_view') %}
async function rescheduleAll() { <script>
const response = await fetch('{{ url_for("reschedule_all") }}', {method: 'POST'}); async function rescheduleAll() {
const json = await response.json(); const response = await fetch('{{ url_for("reschedule_all") }}', {method: 'POST'});
const dialog = document.getElementById('msg'); const json = await response.json();
dialog.innerText = json.msg; const dialog = document.getElementById('msg');
dialog.setAttribute('open', ''); dialog.innerText = json.msg;
setTimeout(() => { dialog.setAttribute('open', '');
dialog.removeAttribute('open'); setTimeout(() => {
}, 1500); dialog.removeAttribute('open');
} }, 1500);
document.getElementById('reschedule-all').addEventListener('click', event => { }
event.preventDefault(); document.getElementById('reschedule-all').addEventListener('click', event => {
rescheduleAll(); event.preventDefault();
}); rescheduleAll();
</script> document.getElementById('nav-menu').open = false;
});
</script>
{% endif %}
</body> </body>
</html> </html>

View file

@ -1,24 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}<h2>{{ domain }}</h2>{% endblock title %} {% block title %}<h2>{{ domain }}</h2>{% endblock title %}
{% block content %} {% block content %}
<div id="domains" class="frame"> <div id="domains" class="overflow-auto">
<table id="domains-list" role="grid"> <table id="domains-list" role="grid" class="striped">
<thead> <thead>
<tr> <tr>
<th>URL</th> <th scope="col">URL</th>
<th>Check</th> <th scope="col">Check</th>
<th>Expected</th> <th scope="col">Current status</th>
<th>Current status</th> <th scope="col">Expected</th>
<th></th> <th scope="col"></th>
</tr> </tr>
</thead> </thead>
<tbody id="domains-body"> <tbody id="domains-body">
{% for task in tasks %} {% for task in tasks %}
<tr> <tr scope="row">
<td>{{ task.url }}</td> <td>{{ task.url }}</td>
<td>{{ task.check }}</td> <td>{{ task.check }}</td>
<td>{{ task.expected }}</td>
<td class="status highlight"> <td class="status highlight">
{% if task.status %} {% if task.status %}
<a data-tooltip="Completed at {{ task.completed_at }}" <a data-tooltip="Completed at {{ task.completed_at }}"
@ -37,6 +36,7 @@
Waiting to be checked Waiting to be checked
{% endif %} {% endif %}
</td> </td>
<td>{{ task.expected }}</td>
<td><a href="{{ url_for('get_task_results_view', task_id=task.id) }}">view all</a></td> <td><a href="{{ url_for('get_task_results_view', task_id=task.id) }}">view all</a></td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -1,11 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}<h2>Dashboard</h2>{% endblock title %} {% block title %}
<h2>Dashboard</h2>
{% endblock title %}
{% block content %} {% block content %}
<div id="domains" class="frame"> <div id="domains" class="frame">
<nav> <nav>
<ul> <ul>
<li> <li>
<a href="{{ url_for('get_agents_view') }}"> <a href="{{ url_for("get_agents_view") }}">
{{ agents | length }} agent{{ 's' if agents | length > 1 }} {{ agents | length }} agent{{ 's' if agents | length > 1 }}
</a> </a>
</li> </li>
@ -21,46 +23,55 @@
</li> </li>
<li> <li>
<label class="inline-label"> <label class="inline-label">
Every <input id="refresh-delay" Every
class="initial-width" <input id="refresh-delay"
name="auto_refresh_seconds" class="initial-width"
type="number" name="auto_refresh_seconds"
form="refresh-form" type="number"
min="5" form="refresh-form"
value="{{ auto_refresh_seconds }}"> seconds min="5"
value="{{ auto_refresh_seconds }}">
seconds
</label> </label>
</li> </li>
<li> <li>
<form id="refresh-form" <form id="refresh-form"
method="post" method="post"
action="{{ url_for('set_refresh_cookies_view') }}"> action="{{ url_for("set_refresh_cookies_view") }}">
<input type="Submit"> <input type="Submit">
</form> </form>
</li> </li>
</ul> </ul>
</nav> </nav>
<div class="container"> <div class="container">
<div class="grid grid-index"> <div class="grid grid-index">
<article> <article>
<header title="Unknown"></header> <header title="Unknown">
</header>
{{ counts_dict['unknown'] }} {{ counts_dict['unknown'] }}
</article> </article>
<article> <article>
<header title="OK"></header> <header title="OK">
</header>
{{ counts_dict['ok'] }} {{ counts_dict['ok'] }}
</article> </article>
<article> <article>
<header title="Warning">⚠️</header> <header title="Warning">
⚠️
</header>
{{ counts_dict['warning'] }} {{ counts_dict['warning'] }}
</article> </article>
<article> <article>
<header title="Critical"></header> <header title="Critical">
</header>
{{ counts_dict['critical'] }} {{ counts_dict['critical'] }}
</article> </article>
</div> </div>
<p class="text-center"> <p class="text-center">
<a href="{{ url_for('get_domains_view') }}" <a href="{{ url_for("get_domains_view") }}"
class="outline" class="outline"
role="button"> role="button">
Domains Domains

View file

@ -0,0 +1,4 @@
location /foo/ {
include proxy_params;
proxy_pass http://127.0.0.1:8000/;
}

View file

@ -9,7 +9,13 @@ These checks are the most basic ones. They simply check that the response from t
| Check | Description | Configuration | | Check | Description | Configuration |
| --- | --- | --- | | --- | --- | --- |
| `status-is` | Check that the returned status code matches what you expect. | `status-is: "200"` | | `status-is` | Check that the returned status code matches what you expect. | `status-is: "200"` |
| `status-in` | Check that the returned status code is in the list of codes you expect. | <pre><code>status-in:<br> - 200<br> - 302</code></pre> |
| `body-contains` | Check that the returned body contains a given string. | `body-contains: "Hello world"` | | `body-contains` | Check that the returned body contains a given string. | `body-contains: "Hello world"` |
| `headers-contain` | Check that the response contains the expected headers. | <pre><code>headers-contain:<br> - "content-encoding"<br> - "content-type"</code></pre> |
| `headers-have` | Check that the response contains the expected headers with the expected value. | <pre><code>headers-have:<br> content-encoding: "gzip"<br> content-type: "text/html"</code></pre> |
| `json-contains` | Check that JSON response contains the expected structure. | <pre><code>json-contains:<br> - /foo/bar/0<br> - /timestamp</code></pre> |
| `json-has` | Check that JSON response contains the expected structure and values. | <pre><code>json-has:<br> /maintenance: false<br> /productname: "Nextcloud"</code></pre> |
| `json-is` | Check that JSON response is the exact expected JSON object | `json-is: '{"foo": "bar", "baz": 42}'`|
```{code-block} yaml ```{code-block} yaml
--- ---
@ -21,6 +27,26 @@ caption: argos-config.yaml
checks: checks:
- status-is: 200 - status-is: 200
- body-contains: "Hello world" - body-contains: "Hello world"
- headers-contain:
- "content-encoding"
- "content-type"
- path: "/foobar"
checks:
- status-in:
- 200
- 302
# Its VERY important to respect the 4 spaces indentation here!
- headers-have:
content-encoding: "gzip"
content-type: "text/html"
- json-contains:
- /foo/bar/0
- /timestamp
# Its VERY important to respect the 4 spaces indentation here!
- json-has:
/maintenance: false
/productname: "Nextcloud"
- json-is: '{"foo": "bar", "baz": 42}'
``` ```
## SSL certificate expiration ## SSL certificate expiration

View file

@ -85,6 +85,7 @@ Commands:
migrate Run database migrations migrate Run database migrations
reload-config Load or reload tasks configuration reload-config Load or reload tasks configuration
start Starts the server (use only for testing or development!) start Starts the server (use only for testing or development!)
test-mail Send a test email
user User management user User management
watch-agents Watch agents (to run routinely) watch-agents Watch agents (to run routinely)
``` ```
@ -465,3 +466,28 @@ Options:
<!--[[[end]]] <!--[[[end]]]
--> -->
#### Test the email settings
You can verify that your mail settings are ok by sending a test email
<!--
.. [[[cog
help(["server", "test-mail", "--help"])
.. ]]] -->
```man
Usage: argos server test-mail [OPTIONS]
Send a test email
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
environment variable is set, its value will be used instead.
--domain TEXT Domain for the notification
--severity TEXT Severity
--help Show this message and exit.
```
<!--[[[end]]]
-->

View file

@ -2,6 +2,8 @@
Argos uses a simple YAML configuration file to define the servers configuration, the websites to monitor and the checks to run on these websites. Argos uses a simple YAML configuration file to define the servers configuration, the websites to monitor and the checks to run on these websites.
See [here](checks.md) for more informations about the checks you can use.
Here is a simple self-documented configuration file, which you can get with [`argos server generate-config`](cli.md#server-generate-config): Here is a simple self-documented configuration file, which you can get with [`argos server generate-config`](cli.md#server-generate-config):
```{literalinclude} ../conf/config-example.yaml ```{literalinclude} ../conf/config-example.yaml

View file

@ -6,3 +6,11 @@ Here is a example for Nginx configuration:
caption: /etc/nginx/sites-available/argos.example.org caption: /etc/nginx/sites-available/argos.example.org
--- ---
``` ```
If you want to use Argos under a subdirectory of your web server, youll need to set the `root_path` setting in Argoss [configuration](../configuration.md) and set Nginx like this:
```{literalinclude} ../../conf/nginx-subdirectory.conf
---
caption: Nginxs location for Argos in a subdirectory
---
```

View file

@ -86,7 +86,7 @@ If you're still experimenting, you can use the [Test PyPI](https://test.pypi.org
```bash ```bash
# Publishing on test PyPI # Publishing on test PyPI
hatch build -r test hatch publish -r test
# Installing from test PyPI # Installing from test PyPI
pip install --index-url https://test.pypi.org/simple/ argos-monitoring pip install --index-url https://test.pypi.org/simple/ argos-monitoring

View file

@ -26,8 +26,9 @@ dependencies = [
"click>=8.1,<9", "click>=8.1,<9",
"fastapi>=0.103,<0.104", "fastapi>=0.103,<0.104",
"fastapi-login>=1.10.0,<2", "fastapi-login>=1.10.0,<2",
"httpx>=0.25,<1", "httpx>=0.25,<0.27.0",
"Jinja2>=3.0,<4", "Jinja2>=3.0,<4",
"jsonpointer>=3.0,<4",
"passlib>=1.7.4,<2", "passlib>=1.7.4,<2",
"psycopg2-binary>=2.9,<3", "psycopg2-binary>=2.9,<3",
"pydantic[email]>=2.4,<3", "pydantic[email]>=2.4,<3",
@ -45,16 +46,18 @@ dependencies = [
dev = [ dev = [
"black==23.3.0", "black==23.3.0",
"djlint>=1.34.0", "djlint>=1.34.0",
"hatch==1.9.4",
"ipdb>=0.13,<0.14", "ipdb>=0.13,<0.14",
"ipython>=8.16,<9", "ipython>=8.16,<9",
"isort==5.11.5", "isort==5.11.5",
"mypy>=1.10.0,<2",
"pylint>=3.0.2", "pylint>=3.0.2",
"pytest-asyncio>=0.21,<1", "pytest-asyncio>=0.21,<1",
"pytest>=6.2.5", "pytest>=6.2.5",
"respx>=0.20,<1", "respx>=0.20,<1",
"ruff==0.1.5,<1", "ruff==0.1.5,<1",
"sphinx-autobuild", "sphinx-autobuild",
"hatch==1.9.4", "types-PyYAML",
] ]
docs = [ docs = [
"cogapp", "cogapp",
@ -103,3 +106,6 @@ filterwarnings = [
"ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning", "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
"ignore:The 'app' shortcut is now deprecated:DeprecationWarning", "ignore:The 'app' shortcut is now deprecated:DeprecationWarning",
] ]
[tool.mypy]
ignore_missing_imports = "True"

View file

@ -10,7 +10,7 @@ os.environ["ARGOS_YAML_FILE"] = "tests/config.yaml"
@pytest.fixture @pytest.fixture
def db() -> Session: def db() -> Session: # type: ignore[misc]
from argos.server import models from argos.server import models
app = _create_app() app = _create_app()
@ -20,7 +20,7 @@ def db() -> Session:
@pytest.fixture @pytest.fixture
def app() -> FastAPI: def app() -> FastAPI: # type: ignore[misc]
from argos.server import models from argos.server import models
app = _create_app() app = _create_app()