🔀 Merge remote-tracking branch 'origin/develop'

This commit is contained in:
Luc Didry 2024-06-24 16:23:00 +02:00
commit 4fcf0e282e
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
50 changed files with 1780 additions and 460 deletions

1
.gitignore vendored
View file

@ -5,5 +5,6 @@ venv
.env
public
*.swp
argos-config.yaml
config.yaml
dist

View file

@ -58,6 +58,7 @@ pages:
- pwd
- ls
- make docs
- echo "https://framasoft.frama.io/framaspace/argos/* https://argos-monitoring.framasoft.org/:splat 301" > public/_redirects
artifacts:
paths:
- public/

View file

@ -2,6 +2,17 @@
## [Unreleased]
- 💄📯 — Improve notifications and result(s) pages
- 🔊 — Add level of log before the log message
— 🔊 — Add a warning messages in the logs if there is no tasks in database. (fix #41)
- ✨ — Add command to generate example configuration (fix #38)
- 📝 — Improve documentation
- ✨ — Add command to warn if its been long since last viewing an agent (fix #49)
- 💥 — Change default config file path to argos-config.yaml (fix #36)
- 📝 — New documentation URL: doc is now on https://argos-monitoring.framasoft.org/
- 💥 — Remove env vars and only use the configuration file
- ✨ — Add built-in authentication for human interface
## 0.1.1
Date: 2024-04-30

View file

@ -2,13 +2,15 @@
A monitoring and status board for your websites.
![Screenshot of Argos status page](docs/dashboard.jpg)
1. Define a list of websites to monitor
2. Specify a list of checks to run on these websites.
3. Argos will run the checks periodically and alert you if something goes wrong.
Internally, a HTTP API is exposed, and a job queue is used to distribute the checks to the agents.
- [Online documentation](http://framasoft.frama.io/framaspace/argos)
- [Online documentation](https://argos-monitoring.framasoft.org/)
- [Issue tracker](https://framagit.org/framasoft/framaspace/argos/-/issues)
## Requirements

View file

@ -1,6 +1,8 @@
import asyncio
import os
from functools import wraps
from pathlib import Path
from sys import exit as sysexit
from uuid import uuid4
import click
@ -32,13 +34,24 @@ def coroutine(f):
def validate_config_access(ctx, param, value):
if os.path.isfile(value) and os.access(value, os.R_OK):
return value
for file in list(
dict.fromkeys([value, "argos-config.yaml", "/etc/argos/config.yaml"])
):
path = Path(file)
if os.path.isfile(value):
raise click.BadParameter(f"the file {value} is not readabale.")
if path.is_file() and os.access(path, os.R_OK):
return file
raise click.BadParameter(f"the file {value} does not exists or is not reachable.")
if value == "argos-config.yaml":
raise click.BadParameter(
f"the file {value} does not exists or is not reachable, "
"nor does /etc/argos/config.yaml."
)
raise click.BadParameter(
f"the file {value} does not exists or is not reachable, "
"nor does argos-config.yaml or /etc/argos/config.yaml."
)
@click.group()
@ -48,11 +61,17 @@ def cli():
@cli.group()
def server():
pass
"""Commands for managing server, servers configuration and users"""
@server.group()
def user():
"""User management"""
@cli.command()
def version():
"""Prints Argos version and exits"""
click.echo(VERSION)
@ -75,7 +94,7 @@ def version():
type=click.Choice(logging.LOG_LEVELS, case_sensitive=False),
)
def agent(server_url, auth, max_tasks, wait_time, log_level):
"""Get and run tasks to the provided server. Will wait for new tasks.
"""Get and run tasks for the provided server. Will wait for new tasks.
Usage: argos agent https://argos.example.org "auth-token-here"
@ -99,9 +118,10 @@ def agent(server_url, auth, max_tasks, wait_time, log_level):
@click.option("--port", default=8000, type=int, help="Port to bind")
@click.option(
"--config",
default="config.yaml",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead. "
"Default value: argos-config.yaml and /etc/argos/config.yaml as fallback.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@ -109,7 +129,7 @@ def agent(server_url, auth, max_tasks, wait_time, log_level):
def start(host, port, config, reload):
"""Starts the server (use only for testing or development!)
See https://framasoft.frama.io/framaspace/argos/deployment/systemd.html#server
See https://argos-monitoring.framasoft.org/deployment/systemd.html#server
for advices on how to start the server for production.
"""
os.environ["ARGOS_YAML_FILE"] = config
@ -147,9 +167,10 @@ def validate_max_results(ctx, param, value):
)
@click.option(
"--config",
default="config.yaml",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead. "
"Default value: argos-config.yaml and /etc/argos/config.yaml as fallback.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@ -175,12 +196,48 @@ async def cleandb(max_results, max_lock_seconds, config):
click.echo(f"{updated} locks released")
@server.command()
@click.option(
"--time-without-agent",
default=5,
help="Time without seeing an agent after which a warning will be issued, in minutes. "
"Default is 5 minutes.",
callback=validate_max_results,
)
@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,
)
@coroutine
async def watch_agents(time_without_agent, config):
"""Watch agents (to run routinely)
Issues a warning if no agent has been seen by the server for a given time.
"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
db = await get_db()
agents = await queries.get_recent_agents_count(db, time_without_agent)
if agents == 0:
click.echo(f"No agent has been seen in the last {time_without_agent} minutes.")
sysexit(1)
@server.command(short_help="Load or reload tasks configuration")
@click.option(
"--config",
default="config.yaml",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead. "
"Default value: argos-config.yaml and /etc/argos/config.yaml as fallback.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@ -192,15 +249,12 @@ async def reload_config(config):
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from argos.server.main import get_application, read_config
from argos.server.settings import get_app_settings
from argos.server.main import read_config
appli = get_application()
settings = get_app_settings()
config = read_config(appli, settings)
_config = read_config(config)
db = await get_db()
changed = await queries.update_from_config(db, config)
changed = await queries.update_from_config(db, _config)
click.echo(f"{changed['added']} tasks added")
click.echo(f"{changed['vanished']} tasks deleted")
@ -209,9 +263,10 @@ async def reload_config(config):
@server.command()
@click.option(
"--config",
default="config.yaml",
default="argos-config.yaml",
help="Path of the configuration file. "
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead. "
"Default value: argos-config.yaml and /etc/argos/config.yaml as fallback.",
envvar="ARGOS_YAML_FILE",
callback=validate_config_access,
)
@ -222,16 +277,253 @@ async def migrate(config):
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server.settings import get_app_settings
from argos.server.settings import read_yaml_config
settings = get_app_settings()
settings = read_yaml_config(config)
current_dir = os.path.dirname(__file__)
alembic_cfg = Config(os.path.join(current_dir, "server/migrations/alembic.ini"))
alembic_cfg.set_main_option("sqlalchemy.url", settings.database_url)
current_dir = Path(__file__).resolve().parent
alembic_cfg = Config(current_dir / "server" / "migrations" / "alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", str(settings.general.db.url))
command.upgrade(alembic_cfg, "head")
@user.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("--name", prompt=True, help="Name of the user to create.")
@click.password_option()
@coroutine
async def add(config, name, password):
"""Add new user"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from passlib.context import CryptContext
db = await get_db()
_user = await queries.get_user(db, name)
if _user is not None:
click.echo(f"User {name} already exists.")
sysexit(1)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
await queries.add_user(db, name, pwd_context.hash(password))
click.echo(f"User {name} added.")
@user.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(
"--name", prompt=True, help="Name of the user you want to change the password."
)
@click.password_option()
@coroutine
async def change_password(config, name, password):
"""Change users password"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from passlib.context import CryptContext
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
_user.password = pwd_context.hash(password)
db.commit()
click.echo(f"Password of user {name} changed.")
@user.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(
"--name", required=True, help="Name of the user you want to test the password for."
)
@click.option("--password", prompt=True, hide_input=True)
@coroutine
async def verify_password(config, name, password):
"""Test users password"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
from passlib.context import CryptContext
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
if not pwd_context.verify(password, _user.password):
click.echo("Wrong password!")
sysexit(2)
click.echo("The provided password is correct.")
@user.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("--name", required=True, help="Name of the user to disable.")
@coroutine
async def disable(config, name):
"""Disable user"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
if _user.disabled:
click.echo(f"User {name} is already disabled.")
sysexit(2)
_user.disabled = True
db.commit()
click.echo(f"User {name} disabled.")
@user.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("--name", required=True, help="Name of the user to reenable")
@coroutine
async def enable(config, name):
"""Enable user"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
if not _user.disabled:
click.echo(f"User {name} is already enabled.")
sysexit(2)
_user.disabled = False
db.commit()
click.echo(f"User {name} enabled.")
@user.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("--name", required=True, help="Name of the user to delete.")
@coroutine
async def delete(config, name):
"""Delete user"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
db = await get_db()
_user = await queries.get_user(db, name)
if _user is None:
click.echo(f"User {name} does not exist.")
sysexit(1)
db.delete(_user)
db.commit()
click.echo(f"User {name} deleted.")
@user.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,
)
@coroutine
async def show(config):
"""List all users"""
# Its mandatory to do it before the imports
os.environ["ARGOS_YAML_FILE"] = config
# The imports are made here otherwise the agent will need server configuration files.
from argos.server import queries
db = await get_db()
users = await queries.list_users(db)
if users.count() == 0:
click.echo("There is no users in database.")
sysexit(1)
click.echo("✅ means that the user is enabled.")
click.echo("❌ means that the user is disabled.")
for _user in users.all():
status = ""
if _user.disabled:
status = ""
click.echo(f"{status} {_user.username}, last login: {_user.last_login_at}")
@server.command(short_help="Generate a token for agents")
@coroutine
async def generate_token():
@ -242,5 +534,19 @@ async def generate_token():
click.echo(uuid4())
@server.command()
@coroutine
async def generate_config():
"""Output a self-documented example config file.
\b
Redirect the output to a file to save it:
argos server generate-config > /etc/argos/config.yaml
"""
config_example = Path(__file__).resolve().parent / "config-example.yaml"
with config_example.open("r", encoding="utf-8") as f:
print(f.read())
if __name__ == "__main__":
cli()

88
argos/config-example.yaml Normal file
View file

@ -0,0 +1,88 @@
general:
db:
# 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"
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 ?)
# See https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.params.pool_size for details
pool_size: 10
max_overflow: 20
# Can be "production", "dev", "test".
# If not present, default value is "production"
env: "production"
# to get a good string for cookie_secret, run:
# openssl rand -hex 32
cookie_secret: "foo_bar_baz"
# Default delay for checks.
# Can be superseeded in domain configuration.
# For ex., to run checks every minute:
frequency: "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 and gotify below to be able to use them here.
alerts:
ok:
- local
warning:
- local
critical:
- local
unknown:
- local
# Mail configuration is quite straight-forward
# mail:
# mailfrom: no-reply@example.org
# host: 127.0.0.1
# port: 25
# ssl: False
# starttls: False
# auth:
# login: foo
# password: bar
# addresses:
# - foo@admin.example.org
# - bar@admin.example.org
# Create an app on your Gotify server and put its token here
# See https://gotify.net/ for details about Gotify
# gotify:
# - url: https://example.org
# tokens:
# - foo
# - bar
service:
secrets:
# Secrets can be generated using `argos server generate-token`.
# You need at least one. Write them as a list, like:
# - secret_token
ssl:
thresholds:
- "1d": critical
- "5d": warning
# It's also possible to define the checks in another file
# with the include syntax:
#
# websites: !include websites.yaml
#
websites:
- domain: "https://mypads.example.org"
paths:
- path: "/mypads/"
checks:
- status-is: 200
- body-contains: '<div id= "mypads"></div>'
- ssl-certificate-expiration: "on-check"
- path: "/admin/"
checks:
- status-is: 401
- domain: "https://munin.example.org"
frequency: "20m"
paths:
- path: "/"
checks:
- status-is: 301
- path: "/munin/"
checks:
- status-is: 401

View file

@ -1,7 +1,14 @@
import logging
logging.getLogger("passlib").setLevel(logging.ERROR)
LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# Print level before message
logging.basicConfig(format="%(levelname)-9s %(message)s")
# XXX We probably want different loggers for client and server.
logger = logging.getLogger(__name__)

View file

@ -8,17 +8,27 @@ from pydantic import (
BaseModel,
ConfigDict,
HttpUrl,
PostgresDsn,
StrictBool,
EmailStr,
PositiveInt,
field_validator,
)
from pydantic.functional_validators import BeforeValidator
from pydantic.networks import UrlConstraints
from pydantic_core import Url
from typing_extensions import Annotated
from argos.schemas.utils import string_to_duration
Severity = Literal["warning", "error", "critical", "unknown"]
Environment = Literal["dev", "test", "production"]
SQLiteDsn = Annotated[
Url,
UrlConstraints(
allowed_schemes=["sqlite"],
),
]
def parse_threshold(value):
@ -134,10 +144,19 @@ class GotifyUrl(BaseModel):
tokens: List[str]
class DbSettings(BaseModel):
url: PostgresDsn | SQLiteDsn
pool_size: int = 10
max_overflow: int = 20
class General(BaseModel):
"""Frequency for the checks and alerts"""
cookie_secret: str
frequency: int
db: DbSettings
env: Environment = "production"
alerts: Alert
mail: Optional[Mail] = None
gotify: Optional[List[GotifyUrl]] = None

View file

@ -52,7 +52,9 @@ Status: {severity}
Time: {result.submitted_at}
Previous status: {old_severity}
See results of task on {request.url_for('get_task_results_view', task_id=task.id)}
See result on {request.url_for('get_result_view', result_id=result.id)}
See results of task on {request.url_for('get_task_results_view', task_id=task.id)}#{result.id}
"""
mail = f"""\
@ -107,7 +109,9 @@ Status: {severity}
Time: {result.submitted_at}
Previous status: {old_severity}
See results of task on {request.url_for('get_task_results_view', task_id=task.id)}
See result on {request.url_for('get_result_view', result_id=result.id)}
See results of task on {request.url_for('get_task_results_view', task_id=task.id)}#{result.id}
"""
payload = {"title": subject, "message": msg, "priority": priority}

View file

@ -0,0 +1,13 @@
from fastapi import Request
from fastapi.responses import RedirectResponse
class NotAuthenticatedException(Exception):
pass
def auth_exception_handler(request: Request, exc: NotAuthenticatedException):
"""
Redirect the user to the login page if not logged in
"""
return RedirectResponse(url=request.url_for("login_view"))

View file

@ -1,79 +1,59 @@
import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi_login import LoginManager
from pydantic import ValidationError
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from argos.logging import logger
from argos.server import models, routes
from argos.server.settings import get_app_settings, read_yaml_config
from argos.server import models, routes, queries
from argos.server.exceptions import NotAuthenticatedException, auth_exception_handler
from argos.server.settings import read_yaml_config
def get_application() -> FastAPI:
"""Spawn Argos FastAPI server"""
settings = get_app_settings()
appli = FastAPI()
appli = FastAPI(lifespan=lifespan)
config_file = os.environ["ARGOS_YAML_FILE"]
config = read_config(appli, settings)
config = read_config(config_file)
# Settings is the pydantic settings object
# Config is the argos config object (built from yaml)
appli.state.config = config
appli.state.settings = settings
appli.add_exception_handler(NotAuthenticatedException, auth_exception_handler)
appli.state.manager = create_manager(config.general.cookie_secret)
@appli.state.manager.user_loader()
async def query_user(user: str) -> None | models.User:
"""
Get a user from the db
:param user: name of the user
:return: None or the user object
"""
return await queries.get_user(appli.state.db, user)
appli.add_event_handler(
"startup",
create_start_app_handler(appli),
)
appli.add_event_handler(
"shutdown",
create_stop_app_handler(appli),
)
appli.include_router(routes.api, prefix="/api")
appli.include_router(routes.views)
static_dir = os.path.join(os.path.dirname(__file__), "static")
static_dir = Path(__file__).resolve().parent / "static"
appli.mount("/static", StaticFiles(directory=static_dir), name="static")
return appli
def create_start_app_handler(appli):
"""Warmup the server:
setup database connection
"""
async def _get_db():
setup_database(appli)
return await connect_to_db(appli)
return _get_db
async def connect_to_db(appli):
appli.state.db = appli.state.SessionLocal()
return appli.state.db
def create_stop_app_handler(appli):
"""Gracefully shutdown the server:
close database connection.
"""
async def stop_app():
appli.state.db.close()
return stop_app
def read_config(appli, settings):
def read_config(yaml_file):
try:
config = read_yaml_config(settings.yaml_file)
appli.state.config = config
config = read_yaml_config(yaml_file)
return config
except ValidationError as err:
logger.error("Errors where found while reading configuration:")
@ -83,25 +63,26 @@ def read_config(appli, settings):
def setup_database(appli):
settings = appli.state.settings
config = appli.state.config
db_url = str(config.general.db.url)
logger.debug("Using database URL %s", db_url)
# For sqlite, we need to add connect_args={"check_same_thread": False}
logger.debug("Using database URL %s", settings.database_url)
if settings.database_url.startswith("sqlite:////tmp"):
if config.general.env == "production" and db_url.startswith("sqlite:////tmp"):
logger.warning("Using sqlite in /tmp is not recommended for production")
extra_settings = {}
if settings.db_pool_size:
extra_settings.setdefault("pool_size", settings.db_pool_size)
if config.general.db.pool_size:
extra_settings.setdefault("pool_size", config.general.db.pool_size)
if settings.db_max_overflow:
extra_settings.setdefault("max_overflow", settings.db_max_overflow)
if config.general.db.max_overflow:
extra_settings.setdefault("max_overflow", config.general.db.max_overflow)
engine = create_engine(settings.database_url, **extra_settings)
engine = create_engine(db_url, **extra_settings)
def _fk_pragma_on_connect(dbapi_con, con_record):
dbapi_con.execute("pragma foreign_keys=ON")
if settings.database_url.startswith("sqlite:////"):
if db_url.startswith("sqlite:///"):
event.listen(engine, "connect", _fk_pragma_on_connect)
appli.state.SessionLocal = sessionmaker(
@ -111,4 +92,40 @@ def setup_database(appli):
models.Base.metadata.create_all(bind=engine)
def create_manager(cookie_secret):
if cookie_secret == "foo_bar_baz":
logger.warning(
"You should change the cookie_secret secret in your configuration file."
)
return LoginManager(
cookie_secret,
"/login",
use_cookie=True,
use_header=False,
not_authenticated_exception=NotAuthenticatedException,
)
@asynccontextmanager
async def lifespan(appli):
"""Server start and stop actions
Setup database connection then close it at shutdown.
"""
setup_database(appli)
db = await connect_to_db(appli)
tasks_count = await queries.count_tasks(db)
if tasks_count == 0:
logger.warning(
"There is no tasks in the database. "
'Please launch the command "argos server reload-config"'
)
yield
appli.state.db.close()
app = get_application()

View file

@ -0,0 +1,35 @@
"""Add users table
Revision ID: c780864dc407
Revises: defda3f2952d
Create Date: 2024-06-10 16:31:17.296983
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c780864dc407"
down_revision: Union[str, None] = "defda3f2952d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("username", sa.String(), nullable=False),
sa.Column("password", sa.String(), nullable=False),
sa.Column("disabled", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("username"),
)
def downgrade() -> None:
op.drop_table("users")

View file

@ -138,3 +138,18 @@ class ConfigCache(Base):
name: Mapped[str] = mapped_column(primary_key=True)
val: Mapped[str] = mapped_column()
updated_at: Mapped[datetime] = mapped_column()
class User(Base):
"""Database model for user authentication"""
__tablename__ = "users"
username: Mapped[str] = mapped_column(primary_key=True)
password: Mapped[str] = mapped_column()
disabled: Mapped[bool] = mapped_column()
created_at: Mapped[datetime] = mapped_column(default=datetime.now())
updated_at: Mapped[datetime] = mapped_column(nullable=True)
last_login_at: Mapped[datetime] = mapped_column(nullable=True)
def update_last_login_at(self):
self.last_login_at = datetime.now()

View file

@ -4,12 +4,12 @@ from hashlib import sha256
from typing import List
from urllib.parse import urljoin
from sqlalchemy import desc, func
from sqlalchemy import asc, desc, func
from sqlalchemy.orm import Session
from argos import schemas
from argos.logging import logger
from argos.server.models import Result, Task, ConfigCache
from argos.server.models import Result, Task, ConfigCache, User
async def list_tasks(db: Session, agent_id: str, limit: int = 100):
@ -32,6 +32,25 @@ async def list_tasks(db: Session, agent_id: str, limit: int = 100):
return tasks
async def add_user(db: Session, name: str, password: str) -> User:
user = User(
username=name,
password=password,
disabled=False,
)
db.add(user)
db.commit()
return user
async def get_user(db: Session, username: str) -> None | User:
return db.get(User, username)
async def list_users(db: Session):
return db.query(User).order_by(asc(User.username))
async def get_task(db: Session, task_id: int) -> Task:
return db.get(Task, task_id)
@ -48,12 +67,13 @@ async def create_result(db: Session, agent_result: schemas.AgentResult, agent_id
return result
async def count_tasks(db: Session, selected=False):
async def count_tasks(db: Session, selected: None | bool = None):
query = db.query(Task)
if selected:
query = query.filter(Task.selected_by is not None)
else:
query = query.filter(Task.selected_by is None)
if selected is not None:
if selected:
query = query.filter(Task.selected_by is not None)
else:
query = query.filter(Task.selected_by is None)
return query.count()
@ -240,3 +260,11 @@ async def release_old_locks(db: Session, max_lock_seconds: int):
)
db.commit()
return updated
async def get_recent_agents_count(db: Session, minutes: int):
"""Get agents seen less than <minutes> ago"""
max_time = datetime.now() - timedelta(minutes=minutes)
agents = db.query(Result.agent_id).filter(Result.submitted_at > max_time).distinct()
return agents.count()

View file

@ -16,6 +16,10 @@ def get_config(request: Request):
return request.app.state.config
async def get_manager(request: Request):
return await request.app.state.manager(request)
async def verify_token(
request: Request, token: HTTPAuthorizationCredentials = Depends(auth_scheme)
):

View file

@ -1,31 +1,99 @@
"""Web interface for humans"""
from collections import defaultdict
from datetime import datetime, timedelta
from functools import cmp_to_key
from os import path
from pathlib import Path
from typing import Annotated
from urllib.parse import urlparse
from fastapi import APIRouter, Cookie, Depends, Form, Request, status
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.templating import Jinja2Templates
from passlib.context import CryptContext
from sqlalchemy import func
from sqlalchemy.orm import Session
from argos.schemas import Config
from argos.server import queries
from argos.server.models import Result, Task
from argos.server.routes.dependencies import get_config, get_db
from argos.server.models import Result, Task, User
from argos.server.routes.dependencies import get_config, get_db, get_manager
route = APIRouter()
current_dir = path.dirname(__file__)
templates = Jinja2Templates(directory=path.join(current_dir, "../templates"))
current_dir = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=current_dir / ".." / "templates")
SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}
@route.get("/login")
async def login_view(request: Request, msg: str | None = None):
token = request.cookies.get("access-token")
if token is not None:
manager = get_manager(request)
user = await manager.get_current_user(token)
if user is not None:
return RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
if msg == "logout":
msg = "You have been successfully disconnected."
else:
msg = None
return templates.TemplateResponse("login.html", {"request": request, "msg": msg})
@route.post("/login")
async def post_login(
request: Request,
db: Session = Depends(get_db),
data: OAuth2PasswordRequestForm = Depends(),
):
username = data.username
user = await queries.get_user(db, username)
invalid_credentials = templates.TemplateResponse(
"login.html",
{"request": request, "msg": "Sorry, invalid username or bad password."},
)
if user is None:
return invalid_credentials
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
if not pwd_context.verify(data.password, user.password):
return invalid_credentials
user.last_login_at = datetime.now()
db.commit()
manager = request.app.state.manager
token = manager.create_access_token(
data={"sub": username}, expires=timedelta(days=7)
)
response = RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
manager.set_cookie(response, token)
return response
@route.get("/logout")
async def logout_view(request: Request, user: User | None = Depends(get_manager)):
response = RedirectResponse(
request.url_for("login_view").include_query_params(msg="logout"),
status_code=status.HTTP_303_SEE_OTHER,
)
response.delete_cookie(key="access-token")
return response
@route.get("/")
async def get_severity_counts_view(
request: Request,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
auto_refresh_enabled: Annotated[bool, Cookie()] = False,
auto_refresh_seconds: Annotated[int, Cookie()] = 15,
@ -48,7 +116,11 @@ async def get_severity_counts_view(
@route.get("/domains")
async def get_domains_view(request: Request, db: Session = Depends(get_db)):
async def get_domains_view(
request: Request,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
"""Show all tasks and their current state"""
tasks = db.query(Task).all()
@ -96,7 +168,10 @@ async def get_domains_view(request: Request, db: Session = Depends(get_db)):
@route.get("/domain/{domain}")
async def get_domain_tasks_view(
request: Request, domain: str, db: Session = Depends(get_db)
request: Request,
domain: str,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
"""Show all tasks attached to a domain"""
tasks = db.query(Task).filter(Task.domain.contains(f"//{domain}")).all()
@ -107,7 +182,10 @@ async def get_domain_tasks_view(
@route.get("/result/{result_id}")
async def get_result_view(
request: Request, result_id: int, db: Session = Depends(get_db)
request: Request,
result_id: int,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
"""Show the details of a result"""
result = db.query(Result).get(result_id)
@ -120,6 +198,7 @@ async def get_result_view(
async def get_task_results_view(
request: Request,
task_id: int,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
config: Config = Depends(get_config),
):
@ -144,7 +223,11 @@ async def get_task_results_view(
@route.get("/agents")
async def get_agents_view(request: Request, db: Session = Depends(get_db)):
async def get_agents_view(
request: Request,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
"""Show argos agents and the last time the server saw them"""
last_seen = (
db.query(Result.agent_id, func.max(Result.submitted_at).label("submitted_at"))
@ -160,6 +243,7 @@ async def get_agents_view(request: Request, db: Session = Depends(get_db)):
@route.post("/refresh")
async def set_refresh_cookies_view(
request: Request,
user: User | None = Depends(get_manager),
auto_refresh_enabled: Annotated[bool, Form()] = False,
auto_refresh_seconds: Annotated[int, Form()] = 15,
):
@ -168,5 +252,7 @@ async def set_refresh_cookies_view(
status_code=status.HTTP_303_SEE_OTHER,
)
response.set_cookie(key="auto_refresh_enabled", value=auto_refresh_enabled)
response.set_cookie(key="auto_refresh_seconds", value=int(auto_refresh_seconds))
response.set_cookie(
key="auto_refresh_seconds", value=max(5, int(auto_refresh_seconds))
)
return response

View file

@ -1,85 +1,21 @@
"""Pydantic schemas for server"""
import os
from functools import lru_cache
from os import environ
from typing import Optional, Union
from pathlib import Path
import yaml
from pydantic_settings import BaseSettings, SettingsConfigDict
from yamlinclude import YamlIncludeConstructor
from argos.schemas.config import Config
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="argos_", env_file=".env")
app_env: str
database_url: str
yaml_file: str
db_pool_size: Optional[int]
db_max_overflow: Optional[int]
class DevSettings(Settings):
"""Settings for dev environment.
Uses config.yaml as config file.
Uses a SQLite database."""
app_env: str = "dev"
yaml_file: str = "config.yaml"
db_pool_size: Optional[int] = None
db_max_overflow: Optional[int] = None
database_url: str = "sqlite:////tmp/argos.db"
class TestSettings(Settings):
"""Settings for test environment.
Uses tests/config.yaml as config file.
Uses a SQLite database."""
app_env: str = "test"
yaml_file: str = "tests/config.yaml"
database_url: str = "sqlite:////tmp/test-argos.db"
db_pool_size: Optional[int] = None
db_max_overflow: Optional[int] = None
class ProdSettings(Settings):
"""Settings for prod environment."""
app_env: str = "prod"
db_pool_size: Optional[int] = 10
db_max_overflow: Optional[int] = 20
environments = {
"dev": DevSettings,
"prod": ProdSettings,
"test": TestSettings,
}
@lru_cache()
def get_app_settings() -> Union[None, Settings]:
"""Load settings depending on the environment"""
app_env = environ.get("ARGOS_APP_ENV", "dev")
settings = environments.get(app_env)
if settings is not None:
return settings()
return None
def read_yaml_config(filename):
parsed = _load_yaml(filename)
return Config(**parsed)
def _load_yaml(filename):
base_dir = os.path.dirname(filename)
base_dir = Path(filename).resolve().parent
YamlIncludeConstructor.add_to_loader_class(
loader_class=yaml.FullLoader, base_dir=base_dir
loader_class=yaml.FullLoader, base_dir=str(base_dir)
)
with open(filename, "r", encoding="utf-8") as stream:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -13,7 +13,7 @@ body > main {
}
h2 {
margin-bottom: calc(var(--typography-spacing-vertical) * 0.5);
margin-bottom: calc(var(--pico-typography-spacing-vertical) * 0.5);
}
.grid-index {
@ -26,10 +26,12 @@ h2 {
.grid-index article {
margin-top: 0;
margin-bottom: 1rem;
padding-bottom: calc(var(--block-spacing-vertical) * 0.7);
padding-bottom: calc(var(--pico-block-spacing-vertical) * 2.4);
}
.grid-index article > header {
margin-bottom: calc(var(--block-spacing-vertical) * 0.7);
padding-top: calc(var(--pico-block-spacing-vertical) * 2.4);
padding-bottom: calc(var(--pico-block-spacing-vertical) * 2.4);
margin-bottom: calc(var(--pico-block-spacing-vertical) * 2.4);
}
label[for="select-status"] {

View file

@ -32,31 +32,52 @@
</li>
</ul>
</a>
{% if request.url.remove_query_params('msg') != url_for('login_view') %}
<ul>
<li>
<a href="{{ url_for('get_severity_counts_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_severity_counts_view') }}"
role="button">
Dashboard
</a>
<a href="{{ url_for('get_domains_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_domains_view') }}"
role="button">
Domains
</a>
<a href="{{ url_for('get_agents_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}"
role="button">
Agents
</a>
<a href="#"
id="reschedule-all"
class="outline"
title="Reschedule non-ok checks as soon as possible">
🕐
</a>
</li>
<details class="dropdown">
<summary autofocus>Menu</summary>
<ul>
<li>
<a href="{{ url_for('get_severity_counts_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_severity_counts_view') }}"
role="button">
Dashboard
</a>
</li>
<li>
<a href="{{ url_for('get_domains_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_domains_view') }}"
role="button">
Domains
</a>
</li>
<li>
<a href="{{ url_for('get_agents_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}"
role="button">
Agents
</a>
</li>
<li>
<a href="#"
id="reschedule-all"
class="outline"
title="Reschedule non-ok checks as soon as possible"
role="button">
Reschedule non-ok checks
</a>
</li>
<li>
<a href="{{ url_for('logout_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}"
role="button">
Logout
</a>
</li>
</ul>
</details>
</ul>
{% endif %}
</nav>
</header>
<main class="container">
@ -71,10 +92,10 @@
</div>
</main>
<footer class="text-center">
<a href="https://framasoft.frama.io/framaspace/argos/">Argos Panoptès</a>,
<a href="https://argos-monitoring.framasoft.org/">Argos Panoptès</a>,
<a href="https://framagit.org/framasoft/framaspace/argos/-/blob/main/LICENSE">AGPLv3</a>
(<a href="https://framagit.org/framasoft/framaspace/argos">sources</a>)
<br/>
<br>
API documentation:
<a href="{{ url_for('get_severity_counts_view') }}docs">Swagger</a>
or

View file

@ -26,7 +26,7 @@
name="auto_refresh_seconds"
type="number"
form="refresh-form"
min="1"
min="5"
value="{{ auto_refresh_seconds }}"> seconds
</label>
</li>

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}<h2>Login</h2>{% endblock title %}
{% block content %}
{% if msg is not none %}
<article>{{ msg }}</article>
{% endif %}
<div class="frame">
<label for="username">Username</label>
<input id="username"
name="username"
type="text"
form="login"
autofocus>
<label for="password">Password</label>
<input id="password"
name="password"
type="password"
form="login">
<form id="login"
method="post"
action="{{ url_for('post_login') }}">
<input type="Submit">
</form>
</div>
{% endblock content %}

View file

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}<h2>{{ result }}</h2>{% endblock title %}
{% block title %}<h2>Result {{ result.id }} - {{ result.status }}</h2>{% endblock title %}
{% block content %}
<dl>
<dt>Task</dt>

View file

@ -13,7 +13,7 @@
</thead>
<tbody>
{% for result in results %}
<tr>
<tr id="{{ result.id }}">
<td>{{ result.submitted_at }}</td>
<td>{{ result.status }}</td>
<td>{{ result.severity }}</td>
@ -22,5 +22,4 @@
{% endfor %}
</tbody>
</table>
{% endblock content %}

View file

@ -1,4 +1,7 @@
ARGOS_YAML_FILE = "my-config.yaml"
ARGOS_DATABASE_URL = "postgresql://argos:argos@localhost/argos"
DB_POOL_SIZE = 10 # https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.params.pool_size
DB_MAX_OVERFLOW = 20
ARGOS_YAML_FILE="my-config.yaml"
ARGOS_DATABASE_URL="postgresql://argos:argos@localhost/argos"
# https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.params.pool_size
DB_POOL_SIZE=10
# https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.params.max_overflow
DB_MAX_OVERFLOW=20

View file

@ -1,63 +0,0 @@
general:
frequency: "1m" # Run checks every minute.
# Which way do you want to be warned when a check goes to that severity?
alerts:
ok:
- local
warning:
- local
critical:
- local
unknown:
- local
# mail:
# mailfrom: no-reply@example.org
# host: 127.0.0.1
# port: 25
# ssl: False
# starttls: False
# auth:
# login: foo
# password: bar
# addresses:
# - foo@admin.example.org
# - bar@admin.example.org
# gotify:
# - url: https://example.org
# tokens:
# - foo
# - bar
service:
secrets:
# Secrets can be generated using `openssl rand -base64 32`.
ssl:
thresholds:
- "1d": critical
- "5d": warning
# It's also possible to define the checks in another file
# with the include syntax:
#
# websites: !include websites.yaml
#
websites:
- domain: "https://mypads.example.org"
paths:
- path: "/mypads/"
checks:
- status-is: 200
- body-contains: '<div id= "mypads"></div>'
- ssl-certificate-expiration: "on-check"
- path: "/admin/"
checks:
- status-is: 401
- domain: "https://munin.example.org"
paths:
- path: "/"
checks:
- status-is: 301
- path: "/munin/"
checks:
- status-is: 401

1
conf/config-example.yaml Symbolic link
View file

@ -0,0 +1 @@
../argos/config-example.yaml

5
conf/default-argos-agent Normal file
View file

@ -0,0 +1,5 @@
ARGOS_AGENT_TOKEN=Secret
ARGOS_AGENT_SERVER_URL=http://127.0.0.1:8000
ARGOS_AGENT_LOGLEVEL=WARNING
ARGOS_AGENT_MAX_TASKS=20
ARGOS_AGENT_WAIT_TIME=10

View file

@ -0,0 +1,5 @@
ARGOS_YAML_FILE="/etc/argos/config.yaml"
ARGOS_SERVER_WORKERS=4
ARGOS_SERVER_SOCKET=127.0.0.1:8000
# Comma separated list of IP addresses of the web proxy (usually Nginx)
ARGOS_SERVER_FORWARDED_ALLOW_IPS=127.0.0.1

View file

@ -9,21 +9,15 @@ server {
ssl_certificate /etc/letsencrypt/live/argos.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/argos.example.org/privkey.pem;
access_log /var/log/nginx/argos.example.org.access.log;
error_log /var/log/nginx/argos.example.org.error.log;
access_log /var/log/nginx/argos.example.org.access.log;
error_log /var/log/nginx/argos.example.org.error.log;
if ($scheme != "https") {
rewrite ^ https://$http_host$request_uri? permanent;
}
location ~ ^/($|domains?/|result/|task/|refresh/) {
auth_basic "Closed site";
auth_basic_user_file argos.passwd;
include proxy_params;
proxy_pass http://127.0.0.1:8000;
}
location / {
include proxy_params;
proxy_pass http://127.0.0.1:8000;
include proxy_params;
proxy_pass http://127.0.0.1:8000;
}
}

View file

@ -1,21 +1,17 @@
[Unit]
Description=Argos agent
Documentation=https://framasoft.frama.io/framaspace/argos/
Documentation=https://argos-monitoring.framasoft.org/
Requires=network.target
After=network.target
[Service]
User=www-data
Environment="ARGOS_AGENT_TOKEN=Secret"
Environment="ARGOS_AGENT_SERVER_URL=http://127.0.0.1:8000"
WorkingDirectory=/var/www/argos/
ExecStart=/var/www/argos/venv/bin/argos agent --max-tasks 20 --wait-time 10 --log-level DEBUG
User=argos
EnvironmentFile=/etc/default/argos-agent
WorkingDirectory=/opt/argos/
ExecStart=/opt/argos/venv/bin/argos agent --max-tasks $ARGOS_AGENT_MAX_TASKS \
--wait-time $ARGOS_AGENT_WAIT_TIME \
--log-level $ARGOS_AGENT_LOGLEVEL
SyslogIdentifier=argos-agent
[Install]
WantedBy=multi-user.target
# NB: it may be better to
# - use a dedicated user
# - use a EnvironmentFile=/etc/default/argos-agent in order to enable configuration
# changes without doing a systemctl daemon-reload

View file

@ -1,24 +1,23 @@
[Unit]
Description=Argos server
Documentation=https://framasoft.frama.io/framaspace/argos/
Documentation=https://argos-monitoring.framasoft.org/
Requires=network.target postgresql.service
After=network.target postgresql.service
PartOf=postgresql.service
[Service]
User=www-data
WorkingDirectory=/var/www/argos/
Environment="ARGOS_SERVER_WORKERS=4"
Environment="ARGOS_SERVER_SOCKET=127.0.0.1:8000"
ExecStartPre=/var/www/argos/venv/bin/argos server migrate
ExecStartPre=/var/www/argos/venv/bin/argos server reload-config
ExecStart=/var/www/argos/venv/bin/gunicorn "argos.server.main:get_application()" -w $ARGOS_SERVER_WORKERS -k uvicorn.workers.UvicornWorker -b $ARGOS_SERVER_SOCKET
ExecReload=/var/www/argos/venv/bin/argos server reload
User=argos
WorkingDirectory=/opt/argos/
EnvironmentFile=/etc/default/argos-server
ExecStartPre=/opt/argos/venv/bin/argos server migrate
ExecStartPre=/opt/argos/venv/bin/argos server reload-config
ExecStart=/opt/argos/venv/bin/gunicorn "argos.server.main:get_application()" \
--workers $ARGOS_SERVER_WORKERS \
--worker-class uvicorn.workers.UvicornWorker \
--bind $ARGOS_SERVER_SOCKET \
--forwarded-allow-ips $ARGOS_SERVER_FORWARDED_ALLOW_IPS
ExecReload=/opt/argos/venv/bin/argos server reload-config
SyslogIdentifier=argos-server
[Install]
WantedBy=multi-user.target
# NB: it may be better to
# - use a EnvironmentFile=/etc/default/argos-server in order to enable configuration
# changes without doing a systemctl daemon-reload

View file

@ -13,7 +13,7 @@ These checks are the most basic ones. They simply check that the response from t
```{code-block} yaml
---
caption: config.yaml
caption: argos-config.yaml
---
- domain: "https://example.org"
paths:
@ -30,7 +30,7 @@ caption: config.yaml
```{code-block} yaml
---
caption: config.yaml
caption: argos-config.yaml
---
ssl:
thresholds:
@ -42,4 +42,4 @@ ssl:
- path: "/"
checks:
- ssl-certificate-expiration: "on-check"
```
```

View file

@ -26,9 +26,9 @@ Options:
--help Show this message and exit.
Commands:
agent Get and run tasks to the provided server.
server
version
agent Get and run tasks for the provided server.
server Commands for managing server, servers configuration and users
version Prints Argos version and exits
```
<!--[[[end]]]
@ -43,7 +43,7 @@ Commands:
```man
Usage: argos agent [OPTIONS] SERVER_URL AUTH
Get and run tasks to the provided server. Will wait for new tasks.
Get and run tasks for the provided server. Will wait for new tasks.
Usage: argos agent https://argos.example.org "auth-token-here"
@ -73,15 +73,20 @@ Options:
```man
Usage: argos server [OPTIONS] COMMAND [ARGS]...
Commands for managing server, servers configuration and users
Options:
--help Show this message and exit.
Commands:
cleandb Clean the database (to run routinely)
generate-token Generate a token for agents
migrate Run database migrations
reload-config Load or reload tasks configuration
start Starts the server (use only for testing or development!)
cleandb Clean the database (to run routinely)
generate-config Output a self-documented example config file.
generate-token Generate a token for agents
migrate Run database migrations
reload-config Load or reload tasks configuration
start Starts the server (use only for testing or development!)
user User management
watch-agents Watch agents (to run routinely)
```
<!--[[[end]]]
@ -98,14 +103,16 @@ Usage: argos server start [OPTIONS]
Starts the server (use only for testing or development!)
See https://framasoft.frama.io/framaspace/argos/deployment/systemd.html#server
for advices on how to start the server for production.
See https://argos-monitoring.framasoft.org/deployment/systemd.html#server for
advices on how to start the server for production.
Options:
--host TEXT Host to bind
--port INTEGER Port to bind
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
variable is set, its value will be used instead. Default
value: argos-config.yaml and /etc/argos/config.yaml as
fallback.
--reload Enable hot reloading
--help Show this message and exit.
```
@ -127,7 +134,8 @@ Usage: argos server migrate [OPTIONS]
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
variable is set, its value will be used instead. Default value:
argos-config.yaml and /etc/argos/config.yaml as fallback.
--help Show this message and exit.
```
@ -156,7 +164,38 @@ Options:
checks have a timeout value of 60 seconds)
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
environment variable is set, its value will be
used instead.
used instead. Default value: argos-config.yaml and
/etc/argos/config.yaml as fallback.
--help Show this message and exit.
```
<!--[[[end]]]
-->
### Server watch-agents
<!--
.. [[[cog
help(["server", "cleandb", "--help"])
.. ]]] -->
```man
Usage: argos server cleandb [OPTIONS]
Clean the database (to run routinely)
- Removes old results from the database.
- Removes locks from tasks that have been locked for too long.
Options:
--max-results INTEGER Number of results per task to keep
--max-lock-seconds INTEGER The number of seconds after which a lock is
considered stale, must be higher than 60 (the
checks have a timeout value of 60 seconds)
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
environment variable is set, its value will be
used instead. Default value: argos-config.yaml and
/etc/argos/config.yaml as fallback.
--help Show this message and exit.
```
@ -177,14 +216,37 @@ Usage: argos server reload-config [OPTIONS]
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
variable is set, its value will be used instead. Default value:
argos-config.yaml and /etc/argos/config.yaml as fallback.
--help Show this message and exit.
```
<!--[[[end]]]
-->
### Server generate-token command
### Server generate-config
<!--
.. [[[cog
help(["server", "generate-config", "--help"])
.. ]]] -->
```man
Usage: argos server generate-config [OPTIONS]
Output a self-documented example config file.
Redirect the output to a file to save it:
argos server generate-config > /etc/argos/config.yaml
Options:
--help Show this message and exit.
```
<!--[[[end]]]
-->
### Server generate-token
<!--
.. [[[cog
@ -204,3 +266,202 @@ Options:
<!--[[[end]]]
-->
### Server user management
To access Argos web interface, you need to create at least one user.
You can manage users only through CLI.
<!--
.. [[[cog
help(["server", "user", "--help"])
.. ]]] -->
```man
Usage: argos server user [OPTIONS] COMMAND [ARGS]...
User management
Options:
--help Show this message and exit.
Commands:
add Add new user
change-password Change users password
delete Delete user
disable Disable user
enable Enable user
show List all users
verify-password Test users password
```
<!--[[[end]]]
-->
#### Add user
<!--
.. [[[cog
help(["server", "user", "add", "--help"])
.. ]]] -->
```man
Usage: argos server user add [OPTIONS]
Add new user
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
environment variable is set, its value will be used instead.
--name TEXT Name of the user to create.
--password TEXT
--help Show this message and exit.
```
<!--[[[end]]]
-->
#### Change the password of a user
<!--
.. [[[cog
help(["server", "user", "change-password", "--help"])
.. ]]] -->
```man
Usage: argos server user change-password [OPTIONS]
Change users password
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
environment variable is set, its value will be used instead.
--name TEXT Name of the user you want to change the password.
--password TEXT
--help Show this message and exit.
```
<!--[[[end]]]
-->
#### Delete a user
<!--
.. [[[cog
help(["server", "user", "delete", "--help"])
.. ]]] -->
```man
Usage: argos server user delete [OPTIONS]
Delete user
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
--name TEXT Name of the user to delete. [required]
--help Show this message and exit.
```
<!--[[[end]]]
-->
#### Disable a user
Disabling a user prevents the user to login and access Argos web interface but its credentials are still stored in Argos database.
<!--
.. [[[cog
help(["server", "user", "disable", "--help"])
.. ]]] -->
```man
Usage: argos server user disable [OPTIONS]
Disable user
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
--name TEXT Name of the user to disable. [required]
--help Show this message and exit.
```
<!--[[[end]]]
-->
#### Enable a user
Enabling a user prevents the user to login and access Argos web interface.
Obviously, the user needs to exists and to be disabled before using the command.
<!--
.. [[[cog
help(["server", "user", "enable", "--help"])
.. ]]] -->
```man
Usage: argos server user enable [OPTIONS]
Enable user
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
--name TEXT Name of the user to reenable [required]
--help Show this message and exit.
```
<!--[[[end]]]
-->
#### List all users
Show all accounts, with their status (enabled or disabled).
<!--
.. [[[cog
help(["server", "user", "show", "--help"])
.. ]]] -->
```man
Usage: argos server user show [OPTIONS]
List all users
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE environment
variable is set, its value will be used instead.
--help Show this message and exit.
```
<!--[[[end]]]
-->
#### Test the password of a user
You can verify that you have the right password for a user with the following command:
<!--
.. [[[cog
help(["server", "user", "verify-password", "--help"])
.. ]]] -->
```man
Usage: argos server user verify-password [OPTIONS]
Test users password
Options:
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
environment variable is set, its value will be used instead.
--name TEXT Name of the user you want to test the password for.
[required]
--password TEXT
--help Show this message and exit.
```
<!--[[[end]]]
-->

View file

@ -5,6 +5,7 @@
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
# pylint: disable-msg=invalid-name,redefined-builtin
import argos
project = "Argos"

View file

@ -1,56 +1,11 @@
# Configuration
There are actually two configuration files: one for the service and one for the checks.
## Server configuration
The server configuration is done using environment variables. You can put them in a `.env` file at the root of the project.
Here is a list of the useful variables, in the `.env` format:
```{literalinclude} ../conf/.env.example
---
caption: .env
---
```
### Environment variables
Here are the environment variables you can define to configure how the service will behave :
#### ARGOS_YAML_FILE
The path to the yaml configuration file, defining the checks.
#### ARGOS_DATABASE_URL
The database url, as defined [in SQLAlchemy docs](https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls).
For instance, to connect to a postgres database on localhost with user, pass and dbname "argos":
```
ARGOS_DATABASE_URL = "postgresql://argos:argos@localhost/argos"
```
#### DB_POOL_SIZE
#### DB_MAX_OVERFLOW
You configure the size of the database pool of connection, and the max overflow (until when new connections are accepted ?) These are documented [in the SQLAlchemy docs in greater details](https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.params.pool_size)
```bash
DB_POOL_SIZE = 10
DB_MAX_OVERFLOW = 20
```
## Argos "checks" configuration
Argos uses a YAML configuration file to define the websites to monitor and the checks to run on these websites.
Here is a simple configuration file:
Argos uses a simple YAML configuration file to define the servers configuration, the websites to monitor and the checks to run on these websites.
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
---
caption: config.yaml
caption: argos-config.yaml
---
```
```

View file

@ -1,13 +1,6 @@
# Using Nginx as reverse proxy
As Argos has no authentication mechanism for the front-end, you need to protect some routes with HTTP authentication.
To do so on Debian, install `apache2-utils` then create a file containing the wanted credentials:
```bash
htpasswd -c /etc/nginx/argos.passwd argos_admin
```
You can then use this file to protect the front-ends routes:
Here is a example for Nginx configuration:
```{literalinclude} ../../conf/nginx.conf
---
caption: /etc/nginx/sites-available/argos.example.org

View file

@ -4,6 +4,12 @@ Here are the systemd files that can be used to deploy the server and the agents.
## Agent
```{literalinclude} ../../conf/default-argos-agent
---
caption: /etc/default/argos-agent
---
```
```{literalinclude} ../../conf/systemd-agent.service
---
caption: /etc/systemd/system/argos-agent.service
@ -12,6 +18,12 @@ caption: /etc/systemd/system/argos-agent.service
## Server
```{literalinclude} ../../conf/default-argos-server
---
caption: /etc/default/argos-server
---
```
```{literalinclude} ../../conf/systemd-server.service
---
caption: /etc/systemd/system/argos-server.service

View file

@ -0,0 +1,5 @@
# License
Argos is licensed under the terms of the GNU AFFERO GPLv3.
See [LICENSE file](https://framagit.org/framasoft/framaspace/argos/-/blob/main/LICENSE) on the repository.

View file

@ -0,0 +1,34 @@
# Add a notification way
Adding a new notification way is quite simple.
First, you need to think about how you will configure it.
As example, heres how gotify notifications are configured:
```yaml
gotify:
- url: https://example.org
tokens:
- foo
- bar
```
Feel free to open an issue to discuss about your notification way or its configuration before coding!
See [#50](https://framagit.org/framasoft/framaspace/argos/-/issues/50) for example.
Then, youll need to add the pydantic schema matching your config in [`argos/schemas/config.py`](https://framagit.org/framasoft/framaspace/argos/-/blob/main/argos/schemas/config.py).
For gotify, its:
```python
class GotifyUrl(BaseModel):
url: HttpUrl
tokens: List[str]
```
Add the schema to the `General` schema in the same file (dont forget to make it optional).
For gotify, we added this:
```python
gotify: Optional[List[GotifyUrl]] = None
```
Finally, write a function which use your new notification way in [`argos/server/alerting.py`](https://framagit.org/framasoft/framaspace/argos/-/blob/main/argos/server/alerting.py) and use it in the `handle_alert` function of the same file.

View file

@ -17,6 +17,8 @@ You'll need to get an account on [PyPI](https://pypi.org), where the packages wi
Here is the quick version. If you need more details, some parts are explained in more details in the next sections.
```bash
# Be sure you are on the good branch
git checkout main
# Ensure the tests run correctly
make test

34
docs/faq.md Normal file
View file

@ -0,0 +1,34 @@
# FAQ
## How is it different than Nagios?
In a few words, Argos do less things than Nagios, but it makes it more simple.
Nagios can do a lot more than Argos, as it can monitor the load of a server, its disk occupation and so much more.
You can extend the possibilities of Nagios with your own plugins, allowing to monitor almost everything.
Argos can only monitor web sites, in various ways (check the HTTP status, check the certificate validity time…).
On the other hand, configuration and deployment of Argos are very much simpler than Nagios.
## How is it different than statping-ng or Uptime Kuma?
In one word: scalability.
While [statping-ng](https://statping-ng.github.io/) and [Uptime Kumap](https://uptime.kuma.pet/) have a similar goal than Argos, you cant monitor thousands of web sites with them efficiently as their dashboard wants to present you the results of all of your web sites at once… and with the history of the results.
We gave those solutions a try, but fetching thousand of results from the dashboard made the backend overloads.
## Who created Argos?
### Framasoft
Framasoft is a non-profit association founded in 2004, financed by [donations](https://support.framasoft.org/), which is limited to a dozen employees and about thirty volunteers (a group of friends!).
You can find more informations on <https://framasoft.org/>.
We needed a very efficient web sites monitoring tool for one of our project, but didnt had time to develop it, so we hired [Alexis Métaireau](#alexis-metaireau) for that.
### Alexis Métaireau
Alexis is a long-time free software developer, who has worked for Mozilla, created [Pelican](http://getpelican.com/), a static site generator, [I Hate Money](http://ihatemoney.org/), a website for managing group expenses and many more other projects.
See <https://blog.notmyidea.org/> for more informations about him.

View file

@ -37,6 +37,8 @@ installation/postgresql
cli
api
changelog
faq
installation/tl-dr
```
```{toctree}
@ -61,9 +63,11 @@ developer/installation
developer/overview
developer/dependencies
developer/new-check
developer/new-notification-way
developer/models
developer/migrations
developer/tests
developer/release
developer/license
```

View file

@ -1,10 +1,27 @@
# Installation
NB: if you want a quick-installation guide, we [got you covered](tl-dr.md).
## Requirements
- Python 3.11+
- PostgreSQL 13+ (for production)
## Recommendation
Create a dedicated user for argos:
```bash
adduser --home /opt/argos --disabled-login --disabled-password --system argos
```
Do all the manipulations below in `/opt/argos/`, with the user `argos`.
Either use `sudo` or login as `argos` with the following command:
```bash
su argos -s /bin/bash
```
## Install with pip
```bash
@ -12,12 +29,19 @@ pip install argos-monitoring
```
You may want to install Argos in a virtualenv:
```bash
python3 -m venv venv
source venv/bin/activate
pip install argos-monitoring
```
For production, we recommend the use of [Gunicorn](https://gunicorn.org/), which you can install at the same time as Argos:
```bash
pip install "argos-monitoring[gunicorn]"
```
## Install from sources
Once you got the source locally, create a virtualenv and install the dependencies:
@ -28,27 +52,33 @@ source venv/bin/activate
pip install -e .
```
To install gunicorn, use `pip install -e ".[gunicorn]"` instead of `pip install -e .`
## Configure
The quickest way to get started is to get the `config-example.yaml` file from our repository and edit it:
The quickest way to get started is to generate the configuration file from argos and edit it:
```bash
wget https://framagit.org/framasoft/framaspace/argos/-/raw/main/conf/config-example.yaml -O config.yaml
argos server generate-config > argos-config.yaml
```
You can read more about the configuration in the [configuration section](../configuration.md).
### Configure the server
Environment variables are used to configure the server. You can also put them in an `.env` file:
```{literalinclude} ../../conf/.env.example
---
caption: .env
---
For production, we suggest to put your config in `/etc/argos/config.yaml` and restricts the files permissions.
As root:
```bash
mkdir /etc/argos
chown argos: /etc/argos
chmod 700 /etc/argos
```
Please note that the only supported database engines are SQLite for development and PostgreSQL for production.
Then, as `argos`:
```bash
argos server generate-config > /etc/argos/config.yaml
chmod 600 /etc/argos/config.yaml
```
Please note that the only supported database engines are SQLite for development and [PostgreSQL](postgresql.md) for production.
## Apply migrations to database
@ -60,7 +90,7 @@ argos server migrate
## Inject tasks into the database
Argos keeps tasks configuration in database, take from the config file.
Argos keeps tasks configuration in database, taken from the config file.
Populate the database with the tasks:
@ -68,17 +98,7 @@ Populate the database with the tasks:
argos server reload-config
```
## Starting the server
Then you can start the server:
```bash
argos server start
```
The server reads the `yaml` file at startup, and populates the tasks queue with the checks defined in the configuration.
## Generating a token
## Generating a token
The agent needs an authentication token to be able to communicate with the server.
@ -95,10 +115,54 @@ service:
- "auth-token"
```
## Starting the server
Then you can start the server:
```bash
argos server start
```
This way to start the server is not suitable for production, use it only for developing or testing.
## Starting the server for production
For production, you can use [Gunicorn](https://gunicorn.org/) to start the server.
To install Gunicorn in the virtualenv, if you didnt already install Argos that way:
```bash
pip install "argos-monitoring[gunicorn]"
```
To start the server:
```bash
gunicorn "argos.server.main:get_application()" -k uvicorn.workers.UvicornWorker
```
There is some gunicorns options that you should use:
- `-w INT, --workers INT`: the number of worker processes for handling requests. Default is `1`.
- `-b ADDRESS, --bind ADDRESS`: the socket to bind. Default is `127.0.0.1:8000`.
- `--forwarded-allow-ips STRING`: front-end's IPs from which allowed to handle set secure headers as a comma-separated list. Default is `127.0.0.1`.
So, to start the server with 4 workers while listening to `127.0.0.1:8001`:
```bash
gunicorn "argos.server.main:get_application()" -k uvicorn.workers.UvicornWorker -w 4 -b 127.0.0.1:8001
```
Gunicorn has a lot of other options, have a look at `gunicorn --help`.
Argos uses FastAPI, so you can use other ways to start the server.
See <https://fastapi.tiangolo.com/deployment/manually/#asgi-servers> (but Gunicorn is recommended).
See [here](../deployment/systemd.md#server) for a systemd service example and [here](../deployment/nginx.md) for a nginx configuration example.
## Running the agent
You can run the agent on the same machine as the server, or on a different machine.
The only requirement is that the agent can reach the server.
The only requirement is that the agent can reach the server through HTTP or HTTPS.
```bash
argos agent http://localhost:8000 "auth-token"
@ -106,7 +170,7 @@ argos agent http://localhost:8000 "auth-token"
## Cleaning the database
You also have to run cleaning tasks periodically. `argos server clean --help` will give you more information on how to do that.
You have to run cleaning task periodically. `argos server cleandb --help` will give you more information on how to do that.
Here is a crontab example, which will clean the db each hour:
@ -115,3 +179,15 @@ Here is a crontab example, which will clean the db each hour:
# Keeps 10 results per task, and remove tasks locks older than 1 hour
7 * * * * argos server cleandb --max-results 10 --max-lock-seconds 3600
```
## Watch the agents
In order to be sure that agents are up and communicate with the server, you can periodically run the `argos server watch-agents` command.
Here is a crontab example, which will check the agents every 5 minutes:
```bash
*/5 * * * * argos server watch-agents --time-without-agent 10
```
Check the documentation of the command with `argos server watch-agents --help`

158
docs/installation/tl-dr.md Normal file
View file

@ -0,0 +1,158 @@
# TL;DR: fast installation instructions
You want to install Argos fast? Ok, here we go.
## For testing
This is for testing only!
```bash
sudo apt install python3
mkdir /tmp/argos
cd /tmp/argos
python3 -m venv venv
source venv/bin/activate
pip install argos-monitoring
argos server generate-config |
sed -e "s@production@dev@" \
-e "s@url: .postgresql.*@url: \"sqlite:////tmp/argos.db\"@" > argos-config.yaml
argos server migrate
ARGOS_TOKEN=$(argos server generate-token)
sed -e "s@# - secret_token@- $ARGOS_TOKEN@" -i argos-config.yaml
echo "The agent token is $ARGOS_TOKEN"
```
Edit `argos-config.yaml`.
Add some real web sites to test.
Then:
```
argos server reload-config
argos server start --host 0.0.0.0 --port 8000
```
In another terminal:
```
cd /tmp/argos
source venv/bin/activate
argos agent http://127.0.0.1:8000 the_generated_token
```
Then go to `http://127.0.0.1:8000` or `http://the_IP_address_of_your_server:8000`.
## For production
```bash
apt install python3 postgresql
sudo -u postgres createuser -P argos
sudo -u postgres createdb -O argos argos
sudo -u postgres psql -c "ALTER DATABASE argos SET TIMEZONE TO 'UTC';"
adduser --home /opt/argos --disabled-login --disabled-password --system argos
cd /opt/argos
sudo -u argos python3 -m venv venv
sudo -u argos bash -c 'source venv/bin/activate && pip install "argos-monitoring[gunicorn]"'
mkdir /etc/argos
/opt/argos/venv/bin/argos server generate-config > /etc/argos/config.yaml
cat <<EOF > /etc/default/argos-server
ARGOS_YAML_FILE="/etc/argos/config.yaml"
ARGOS_SERVER_WORKERS=4
ARGOS_SERVER_SOCKET=127.0.0.1:8000
# Comma separated list of IP addresses of the web proxy (usually Nginx)
ARGOS_SERVER_FORWARDED_ALLOW_IPS=127.0.0.1
EOF
cat <<EOF > /etc/default/argos-agent
ARGOS_AGENT_TOKEN=Secret
ARGOS_AGENT_SERVER_URL=http://127.0.0.1:8000
ARGOS_AGENT_LOGLEVEL=WARNING
ARGOS_AGENT_MAX_TASKS=20
ARGOS_AGENT_WAIT_TIME=10
EOF
cat <<EOF > /etc/systemd/system/argos-server.service
[Unit]
Description=Argos server
Documentation=https://argos-monitoring.framasoft.org/
Requires=network.target postgresql.service
After=network.target postgresql.service
PartOf=postgresql.service
[Service]
User=argos
WorkingDirectory=/opt/argos/
EnvironmentFile=/etc/default/argos-server
ExecStartPre=/opt/argos/venv/bin/argos server migrate
ExecStartPre=/opt/argos/venv/bin/argos server reload-config
ExecStart=/opt/argos/venv/bin/gunicorn "argos.server.main:get_application()" \\
--workers \$ARGOS_SERVER_WORKERS \\
--worker-class uvicorn.workers.UvicornWorker \\
--bind \$ARGOS_SERVER_SOCKET \\
--forwarded-allow-ips \$ARGOS_SERVER_FORWARDED_ALLOW_IPS
ExecReload=/opt/argos/venv/bin/argos server reload-config
SyslogIdentifier=argos-server
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF > /etc/systemd/system/argos-agent.service
[Unit]
Description=Argos agent
Documentation=https://argos-monitoring.framasoft.org/
Requires=network.target
After=network.target
[Service]
User=argos
EnvironmentFile=/etc/default/argos-agent
WorkingDirectory=/opt/argos/
ExecStart=/opt/argos/venv/bin/argos agent --max-tasks \$ARGOS_AGENT_MAX_TASKS \\
--wait-time \$ARGOS_AGENT_WAIT_TIME \\
--log-level \$ARGOS_AGENT_LOGLEVEL
SyslogIdentifier=argos-agent
[Install]
WantedBy=multi-user.target
EOF
chown -R argos: /etc/default/argos-* /etc/argos/
chmod 700 /etc/argos
chmod 600 /etc/argos/config.yaml
systemctl daemon-reload
```
Then, edit `/etc/argos/config.yaml` to put your database password in it and change the other settings to suit your needs.
Create a token for your agent :
```bash
sudo -u argos /opt/argos/venv/bin/argos server generate-token
```
Edit `/etc/default/argos-agent` to put the generated token in it and change the other settings to suit your needs.
Edit `/etc/argos/config.yaml` to configure Argos (dont forget to add the generated token in it too).
Enable and start the server and the agent and make sure they works:
```bash
systemctl enable --now argos-server.service argos-agent.service
systemctl status argos-server.service argos-agent.service
```
If all works well, you have to put some cron tasks in `argos` crontab:
```bash
cat <<EOF | crontab -u argos -
*/10 * * * * /opt/argos/venv/bin/argos server cleandb --max-lock-seconds 120 --max-results 1200
*/10 * * * * /opt/argos/venv/bin/argos server watch-agents --time-without-agent 10
EOF
```
See the [this page](../deployment/nginx.md) for using Nginx as reverse proxy.

View file

@ -22,11 +22,13 @@ classifiers = [
dependencies = [
"alembic>=1.13.0,<1.14",
"bcrypt>=4.1.3,<5",
"click>=8.1,<9",
"fastapi>=0.103,<0.104",
"gunicorn>=21.2,<22",
"fastapi-login>=1.10.0,<2",
"httpx>=0.25,<1",
"Jinja2>=3.0,<4",
"passlib>=1.7.4,<2",
"psycopg2-binary>=2.9,<3",
"pydantic[email]>=2.4,<3",
"pydantic-settings>=2.0,<3",
@ -62,9 +64,12 @@ docs = [
"sphinx>=7,<8",
"sphinxcontrib-mermaid>=0.9,<1",
]
gunicorn = [
"gunicorn>=21.2,<22",
]
[project.urls]
homepage = "https://framasoft.frama.io/framaspace/argos/"
homepage = "https://argos-monitoring.framasoft.org/"
repository = "https://framagit.org/framasoft/framaspace/argos"
"Funding" = "https://framasoft.org/en/#support"
"Tracker" = "https://framagit.org/framasoft/framaspace/argos/-/issues"
@ -94,3 +99,7 @@ testpaths = [
"argos"
]
pythonpath = "."
filterwarnings = [
"ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
"ignore:The 'app' shortcut is now deprecated:DeprecationWarning",
]

View file

@ -1,4 +1,9 @@
general:
db:
# 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"
env: test
cookie_secret: "foo-bar-baz"
frequency: "1m"
alerts:
ok:

View file

@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
os.environ["ARGOS_APP_ENV"] = "test"
os.environ["ARGOS_YAML_FILE"] = "tests/config.yaml"
@pytest.fixture
@ -45,10 +45,6 @@ def _create_app() -> FastAPI:
)
app = get_application()
# Hardcode the database url and the yaml file for testing purpose
# Otherwise, the app will try to read the .env file or the environment variables
app.state.settings.database_url = "sqlite:////tmp/test-argos.db"
app.state.settings.yaml_file = "tests/config.yaml"
setup_database(app)
asyncio.run(connect_to_db(app))

150
tests/test_cli.py Normal file
View file

@ -0,0 +1,150 @@
import os
from click.testing import CliRunner
from argos.commands import (
add,
verify_password,
change_password,
show,
disable,
enable,
delete,
)
os.environ["ARGOS_APP_ENV"] = "test"
os.environ["ARGOS_YAML_FILE"] = "tests/config.yaml"
def test_add_user():
runner = CliRunner()
result = runner.invoke(add, ["--name", "foo"], input="bar\nbar\n")
assert result.exit_code == 0
assert result.output == "Password: \nRepeat for confirmation: \nUser foo added.\n"
result = runner.invoke(add, ["--name", "foo"], input="bar\nbar\n")
assert result.exit_code == 1
assert (
result.output
== "Password: \nRepeat for confirmation: \nUser foo already exists.\n"
)
result = runner.invoke(add, ["--name", "baz", "--password", "qux"])
assert result.exit_code == 0
assert result.output == "User baz added.\n"
def test_user_password():
runner = CliRunner()
result = runner.invoke(verify_password, ["--name", "foo"], input="bar\n")
assert result.exit_code == 0
assert result.output == "Password: \nThe provided password is correct.\n"
result = runner.invoke(verify_password, ["--name", "foo", "--password", "bar"])
assert result.exit_code == 0
assert result.output == "The provided password is correct.\n"
result = runner.invoke(verify_password, ["--name", "quux", "--password", "corge"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
result = runner.invoke(verify_password, ["--name", "foo", "--password", "grault"])
assert result.exit_code == 2
assert result.output == "Wrong password!\n"
def test_change_password():
runner = CliRunner()
result = runner.invoke(verify_password, ["--name", "foo", "--password", "grault"])
assert result.exit_code == 2
assert result.output == "Wrong password!\n"
result = runner.invoke(change_password, ["--name", "foo"], input="grault\ngrault\n")
assert result.exit_code == 0
assert (
result.output
== "Password: \nRepeat for confirmation: \nPassword of user foo changed.\n"
)
result = runner.invoke(verify_password, ["--name", "foo", "--password", "grault"])
assert result.exit_code == 0
assert result.output == "The provided password is correct.\n"
result = runner.invoke(change_password, ["--name", "foo", "--password", "bar"])
assert result.exit_code == 0
assert result.output == "Password of user foo changed.\n"
result = runner.invoke(verify_password, ["--name", "foo", "--password", "bar"])
assert result.exit_code == 0
assert result.output == "The provided password is correct.\n"
result = runner.invoke(verify_password, ["--name", "quux", "--password", "bar"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
def test_show():
runner = CliRunner()
result = runner.invoke(show)
assert result.exit_code == 0
assert (
result.output
== "✅ means that the user is enabled.\n❌ means that the user is disabled.\n"
"✅ baz, last login: None\n✅ foo, last login: None\n"
)
def test_disable():
runner = CliRunner()
result = runner.invoke(disable, ["--name", "quux"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
result = runner.invoke(disable, ["--name", "foo"])
assert result.exit_code == 0
assert result.output == "User foo disabled.\n"
result = runner.invoke(disable, ["--name", "foo"])
assert result.exit_code == 2
assert result.output == "User foo is already disabled.\n"
result = runner.invoke(show)
assert result.exit_code == 0
assert (
result.output
== "✅ means that the user is enabled.\n❌ means that the user is disabled.\n"
"✅ baz, last login: None\n❌ foo, last login: None\n"
)
def test_enable():
runner = CliRunner()
result = runner.invoke(enable, ["--name", "quux"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
result = runner.invoke(enable, ["--name", "foo"])
assert result.exit_code == 0
assert result.output == "User foo enabled.\n"
result = runner.invoke(enable, ["--name", "foo"])
assert result.exit_code == 2
assert result.output == "User foo is already enabled.\n"
result = runner.invoke(show)
assert result.exit_code == 0
assert (
result.output
== "✅ means that the user is enabled.\n❌ means that the user is disabled.\n"
"✅ baz, last login: None\n✅ foo, last login: None\n"
)
def test_delete():
runner = CliRunner()
result = runner.invoke(delete, ["--name", "quux"])
assert result.exit_code == 1
assert result.output == "User quux does not exist.\n"
result = runner.invoke(delete, ["--name", "foo"])
assert result.exit_code == 0
assert result.output == "User foo deleted.\n"
result = runner.invoke(delete, ["--name", "foo"])
assert result.exit_code == 1
assert result.output == "User foo does not exist.\n"
result = runner.invoke(show)
assert result.exit_code == 0
assert (
result.output
== "✅ means that the user is enabled.\n❌ means that the user is disabled.\n"
"✅ baz, last login: None\n"
)
result = runner.invoke(delete, ["--name", "baz"])
assert result.exit_code == 0
assert result.output == "User baz deleted.\n"
result = runner.invoke(show)
assert result.exit_code == 1
assert result.output == "There is no users in database.\n"

View file

@ -4,18 +4,18 @@ import pytest
from argos import schemas
from argos.server import queries
from argos.server.models import Result, Task
from argos.server.models import Result, Task, User
@pytest.mark.asyncio
async def test_remove_old_results(db, ten_tasks):
for task in ten_tasks:
for i in range(5):
async def test_remove_old_results(db, ten_tasks): # pylint: disable-msg=redefined-outer-name
for _task in ten_tasks:
for _ in range(5):
result = Result(
submitted_at=datetime.now(),
status="success",
context={"foo": "bar"},
task=task,
task=_task,
agent_id="test",
severity="ok",
)
@ -28,8 +28,8 @@ async def test_remove_old_results(db, ten_tasks):
deleted = await queries.remove_old_results(db, 2)
assert deleted == 30
assert db.query(Result).count() == 20
for task in ten_tasks:
assert db.query(Result).filter(Result.task == task).count() == 2
for _task in ten_tasks:
assert db.query(Result).filter(Result.task == _task).count() == 2
@pytest.mark.asyncio
@ -40,7 +40,7 @@ async def test_remove_old_results_with_empty_db(db):
@pytest.mark.asyncio
async def test_release_old_locks(db, ten_locked_tasks, ten_tasks):
async def test_release_old_locks(db, ten_locked_tasks, ten_tasks): # pylint: disable-msg=redefined-outer-name
assert db.query(Task).count() == 20
released = await queries.release_old_locks(db, 10)
assert released == 10
@ -54,9 +54,9 @@ async def test_release_old_locks_with_empty_db(db):
@pytest.mark.asyncio
async def test_update_from_config_with_duplicate_tasks(db, empty_config):
async def test_update_from_config_with_duplicate_tasks(db, empty_config): # pylint: disable-msg=redefined-outer-name
# We pass the same path twice
fake_path = dict(path="/", checks=[{"body-contains": "foo"}])
fake_path = {"path": "/", "checks": [{"body-contains": "foo"}]}
website = schemas.config.Website(
domain="https://example.org",
paths=[
@ -79,7 +79,9 @@ async def test_update_from_config_with_duplicate_tasks(db, empty_config):
@pytest.mark.asyncio
async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
db, empty_config, task
db,
empty_config,
task, # pylint: disable-msg=redefined-outer-name
):
# Add a duplicate in the db
same_task = Task(
@ -96,10 +98,11 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
website = schemas.config.Website(
domain=task.domain,
paths=[
dict(
path="https://another-example.com", checks=[{task.check: task.expected}]
),
dict(path=task.url, checks=[{task.check: task.expected}]),
{
"path": "https://another-example.com",
"checks": [{task.check: task.expected}],
},
{"path": task.url, "checks": [{task.check: task.expected}]},
],
)
empty_config.websites = [website]
@ -110,9 +113,10 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
website = schemas.config.Website(
domain=task.domain,
paths=[
dict(
path="https://another-example.com", checks=[{task.check: task.expected}]
),
{
"path": "https://another-example.com",
"checks": [{task.check: task.expected}],
}
],
)
empty_config.websites = [website]
@ -122,14 +126,12 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
@pytest.mark.asyncio
async def test_update_from_config_db_updates_existing_tasks(db, empty_config, task):
async def test_update_from_config_db_updates_existing_tasks(db, empty_config, task): # pylint: disable-msg=redefined-outer-name
assert db.query(Task).count() == 1
website = schemas.config.Website(
domain=task.domain,
paths=[
dict(path=task.url, checks=[{task.check: task.expected}]),
],
paths=[{"path": task.url, "checks": [{task.check: task.expected}]}],
)
empty_config.websites = [website]
@ -139,7 +141,11 @@ async def test_update_from_config_db_updates_existing_tasks(db, empty_config, ta
@pytest.mark.asyncio
async def test_reschedule_all(
db, ten_tasks, ten_warning_tasks, ten_critical_tasks, ten_ok_tasks
db,
ten_tasks,
ten_warning_tasks,
ten_critical_tasks,
ten_ok_tasks, # pylint: disable-msg=redefined-outer-name
):
assert db.query(Task).count() == 40
assert db.query(Task).filter(Task.severity == "unknown").count() == 10
@ -154,24 +160,73 @@ async def test_reschedule_all(
assert db.query(Task).filter(Task.next_run <= one_hour_ago).count() == 30
@pytest.mark.asyncio
async def test_add_user(db):
users = await queries.list_users(db)
assert users.count() == 0
_user = await queries.add_user(db, "john", "doe")
assert _user.username == "john"
assert _user.password == "doe"
assert _user.disabled == False
assert _user.created_at is not None
assert _user.updated_at is None
assert _user.last_login_at is None
_user = await queries.get_user(db, "morgan")
assert _user is None
_user = await queries.get_user(db, "john")
assert _user.username == "john"
assert _user.password == "doe"
assert _user.disabled == False
assert _user.created_at is not None
assert _user.updated_at is None
assert _user.last_login_at is None
users = await queries.list_users(db)
assert users.count() == 1
@pytest.mark.asyncio
async def test_remove_user(db, user): # pylint: disable-msg=redefined-outer-name
users = await queries.list_users(db)
assert users.count() == 1
assert user.username == "jane"
assert user.password == "doe"
assert user.disabled == False
assert user.created_at is not None
assert user.updated_at is None
assert user.last_login_at is None
db.delete(user)
db.commit()
users = await queries.list_users(db)
assert users.count() == 0
@pytest.fixture
def task(db):
task = Task(
_task = Task(
url="https://www.example.com",
domain="https://www.example.com",
check="body-contains",
expected="foo",
frequency=1,
)
db.add(task)
db.add(_task)
db.commit()
return task
return _task
@pytest.fixture
def empty_config():
return schemas.config.Config(
general=schemas.config.General(
db=schemas.config.DbSettings(url="sqlite:////tmp/test-argos.db"),
cookie_secret="foo-bar-baz",
frequency="1m",
alerts=schemas.config.Alert(
ok=["", ""],
@ -191,9 +246,9 @@ def empty_config():
@pytest.fixture
def ten_results(db, task):
def ten_results(db, task): # pylint: disable-msg=redefined-outer-name
results = []
for i in range(10):
for _ in range(10):
result = Result(
submitted_at=datetime.now(),
status="success",
@ -212,8 +267,8 @@ def ten_results(db, task):
def ten_locked_tasks(db):
a_minute_ago = datetime.now() - timedelta(minutes=1)
tasks = []
for i in range(10):
task = Task(
for _ in range(10):
_task = Task(
url="https://www.example.com",
domain="example.com",
check="body-contains",
@ -222,8 +277,8 @@ def ten_locked_tasks(db):
selected_by="test",
selected_at=a_minute_ago,
)
db.add(task)
tasks.append(task)
db.add(_task)
tasks.append(_task)
db.commit()
return tasks
@ -232,8 +287,8 @@ def ten_locked_tasks(db):
def ten_tasks(db):
now = datetime.now()
tasks = []
for i in range(10):
task = Task(
for _ in range(10):
_task = Task(
url="https://www.example.com",
domain="example.com",
check="body-contains",
@ -242,8 +297,8 @@ def ten_tasks(db):
selected_by="test",
selected_at=now,
)
db.add(task)
tasks.append(task)
db.add(_task)
tasks.append(_task)
db.commit()
return tasks
@ -252,8 +307,8 @@ def ten_tasks(db):
def ten_warning_tasks(db):
now = datetime.now()
tasks = []
for i in range(10):
task = Task(
for _ in range(10):
_task = Task(
url="https://www.example.com",
domain="example.com",
check="body-contains",
@ -262,8 +317,8 @@ def ten_warning_tasks(db):
next_run=now,
severity="warning",
)
db.add(task)
tasks.append(task)
db.add(_task)
tasks.append(_task)
db.commit()
return tasks
@ -272,8 +327,8 @@ def ten_warning_tasks(db):
def ten_critical_tasks(db):
now = datetime.now()
tasks = []
for i in range(10):
task = Task(
for _ in range(10):
_task = Task(
url="https://www.example.com",
domain="example.com",
check="body-contains",
@ -282,8 +337,8 @@ def ten_critical_tasks(db):
next_run=now,
severity="critical",
)
db.add(task)
tasks.append(task)
db.add(_task)
tasks.append(_task)
db.commit()
return tasks
@ -292,8 +347,8 @@ def ten_critical_tasks(db):
def ten_ok_tasks(db):
now = datetime.now()
tasks = []
for i in range(10):
task = Task(
for _ in range(10):
_task = Task(
url="https://www.example.com",
domain="example.com",
check="body-contains",
@ -302,7 +357,19 @@ def ten_ok_tasks(db):
next_run=now,
severity="ok",
)
db.add(task)
tasks.append(task)
db.add(_task)
tasks.append(_task)
db.commit()
return tasks
@pytest.fixture
def user(db):
_user = User(
username="jane",
password="doe",
disabled=False,
)
db.add(_user)
db.commit()
return _user