🔀 Merge branch 'add-accounts' into 'develop'

 — Add built-in authentication for human interface

See merge request framasoft/framaspace/argos!58
This commit is contained in:
Luc Didry 2024-06-24 14:15:22 +00:00
commit 98f2ce6f63
28 changed files with 1144 additions and 223 deletions

View file

@ -11,6 +11,7 @@
- 💥 — Change default config file path to argos-config.yaml (fix #36) - 💥 — Change default config file path to argos-config.yaml (fix #36)
- 📝 — New documentation URL: doc is now on https://argos-monitoring.framasoft.org/ - 📝 — New documentation URL: doc is now on https://argos-monitoring.framasoft.org/
- 💥 — Remove env vars and only use the configuration file - 💥 — Remove env vars and only use the configuration file
- ✨ — Add built-in authentication for human interface
## 0.1.1 ## 0.1.1

View file

@ -61,11 +61,17 @@ def cli():
@cli.group() @cli.group()
def server(): def server():
pass """Commands for managing server, servers configuration and users"""
@server.group()
def user():
"""User management"""
@cli.command() @cli.command()
def version(): def version():
"""Prints Argos version and exits"""
click.echo(VERSION) click.echo(VERSION)
@ -88,7 +94,7 @@ def version():
type=click.Choice(logging.LOG_LEVELS, case_sensitive=False), type=click.Choice(logging.LOG_LEVELS, case_sensitive=False),
) )
def agent(server_url, auth, max_tasks, wait_time, log_level): 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" Usage: argos agent https://argos.example.org "auth-token-here"
@ -200,7 +206,7 @@ async def cleandb(max_results, max_lock_seconds, config):
) )
@click.option( @click.option(
"--config", "--config",
default="config.yaml", default="argos-config.yaml",
help="Path of the configuration file. " 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.",
envvar="ARGOS_YAML_FILE", envvar="ARGOS_YAML_FILE",
@ -281,6 +287,243 @@ async def migrate(config):
command.upgrade(alembic_cfg, "head") 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") @server.command(short_help="Generate a token for agents")
@coroutine @coroutine
async def generate_token(): async def generate_token():
@ -294,7 +537,7 @@ async def generate_token():
@server.command() @server.command()
@coroutine @coroutine
async def generate_config(): async def generate_config():
"""Output an example config file. """Output a self-documented example config file.
\b \b
Redirect the output to a file to save it: Redirect the output to a file to save it:

View file

@ -10,9 +10,16 @@ general:
# Can be "production", "dev", "test". # Can be "production", "dev", "test".
# If not present, default value is "production" # If not present, default value is "production"
env: "production" env: "production"
frequency: "1m" # Run checks every minute. # 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? # Which way do you want to be warned when a check goes to that severity?
# "local" emits a message in the server log # "local" emits a message in the server log
# Youll need to configure mail and gotify below to be able to use them here.
alerts: alerts:
ok: ok:
- local - local
@ -22,6 +29,7 @@ general:
- local - local
unknown: unknown:
- local - local
# Mail configuration is quite straight-forward
# mail: # mail:
# mailfrom: no-reply@example.org # mailfrom: no-reply@example.org
# host: 127.0.0.1 # host: 127.0.0.1
@ -34,6 +42,8 @@ general:
# addresses: # addresses:
# - foo@admin.example.org # - foo@admin.example.org
# - bar@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: # gotify:
# - url: https://example.org # - url: https://example.org
# tokens: # tokens:
@ -68,6 +78,7 @@ websites:
checks: checks:
- status-is: 401 - status-is: 401
- domain: "https://munin.example.org" - domain: "https://munin.example.org"
frequency: "20m"
paths: paths:
- path: "/" - path: "/"
checks: checks:

View file

@ -1,5 +1,9 @@
import logging import logging
logging.getLogger("passlib").setLevel(logging.ERROR)
LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# Print level before message # Print level before message

View file

@ -153,6 +153,7 @@ class DbSettings(BaseModel):
class General(BaseModel): class General(BaseModel):
"""Frequency for the checks and alerts""" """Frequency for the checks and alerts"""
cookie_secret: str
frequency: int frequency: int
db: DbSettings db: DbSettings
env: Environment = "production" env: Environment = "production"

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,36 +1,42 @@
import os import os
import sys import sys
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi_login import LoginManager
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import create_engine, event from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from argos.logging import logger from argos.logging import logger
from argos.server import models, routes, queries from argos.server import models, routes, queries
from argos.server.exceptions import NotAuthenticatedException, auth_exception_handler
from argos.server.settings import read_yaml_config 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() 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)
# 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.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.api, prefix="/api")
appli.include_router(routes.views) appli.include_router(routes.views)
@ -40,44 +46,11 @@ def get_application() -> FastAPI:
return appli return appli
def create_start_app_handler(appli):
"""Warmup the server:
setup database connection
"""
async def _get_db():
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"'
)
return db
return _get_db
async def connect_to_db(appli): async def connect_to_db(appli):
appli.state.db = appli.state.SessionLocal() appli.state.db = appli.state.SessionLocal()
return appli.state.db 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(yaml_file): def read_config(yaml_file):
try: try:
config = read_yaml_config(yaml_file) config = read_yaml_config(yaml_file)
@ -119,4 +92,40 @@ def setup_database(appli):
models.Base.metadata.create_all(bind=engine) 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() 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) name: Mapped[str] = mapped_column(primary_key=True)
val: Mapped[str] = mapped_column() val: Mapped[str] = mapped_column()
updated_at: Mapped[datetime] = 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

@ -1,15 +1,15 @@
"""Functions to ease SQL queries management""" """Functions to ease SQL queries management"""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from hashlib import sha256 from hashlib import sha256
from typing import List, Union from typing import List
from urllib.parse import urljoin from urllib.parse import urljoin
from sqlalchemy import desc, func from sqlalchemy import asc, desc, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from argos import schemas from argos import schemas
from argos.logging import logger 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): 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 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: async def get_task(db: Session, task_id: int) -> Task:
return db.get(Task, task_id) return db.get(Task, task_id)
@ -48,7 +67,7 @@ async def create_result(db: Session, agent_result: schemas.AgentResult, agent_id
return result return result
async def count_tasks(db: Session, selected: Union[None, bool] = None): 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:

View file

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

View file

@ -1,5 +1,6 @@
"""Web interface for humans""" """Web interface for humans"""
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta
from functools import cmp_to_key from functools import cmp_to_key
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@ -7,14 +8,16 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Cookie, Depends, Form, Request, status from fastapi import APIRouter, Cookie, Depends, Form, Request, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from passlib.context import CryptContext
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from argos.schemas import Config from argos.schemas import Config
from argos.server import queries from argos.server import queries
from argos.server.models import Result, Task from argos.server.models import Result, Task, User
from argos.server.routes.dependencies import get_config, get_db from argos.server.routes.dependencies import get_config, get_db, get_manager
route = APIRouter() route = APIRouter()
@ -23,9 +26,74 @@ templates = Jinja2Templates(directory=current_dir / ".." / "templates")
SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4} 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("/") @route.get("/")
async def get_severity_counts_view( async def get_severity_counts_view(
request: Request, request: Request,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db), db: Session = Depends(get_db),
auto_refresh_enabled: Annotated[bool, Cookie()] = False, auto_refresh_enabled: Annotated[bool, Cookie()] = False,
auto_refresh_seconds: Annotated[int, Cookie()] = 15, auto_refresh_seconds: Annotated[int, Cookie()] = 15,
@ -48,7 +116,11 @@ async def get_severity_counts_view(
@route.get("/domains") @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""" """Show all tasks and their current state"""
tasks = db.query(Task).all() 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}") @route.get("/domain/{domain}")
async def get_domain_tasks_view( 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""" """Show all tasks attached to a domain"""
tasks = db.query(Task).filter(Task.domain.contains(f"//{domain}")).all() 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}") @route.get("/result/{result_id}")
async def get_result_view( 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""" """Show the details of a result"""
result = db.query(Result).get(result_id) result = db.query(Result).get(result_id)
@ -120,6 +198,7 @@ async def get_result_view(
async def get_task_results_view( async def get_task_results_view(
request: Request, request: Request,
task_id: int, task_id: int,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db), db: Session = Depends(get_db),
config: Config = Depends(get_config), config: Config = Depends(get_config),
): ):
@ -144,7 +223,11 @@ async def get_task_results_view(
@route.get("/agents") @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""" """Show argos agents and the last time the server saw them"""
last_seen = ( last_seen = (
db.query(Result.agent_id, func.max(Result.submitted_at).label("submitted_at")) 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") @route.post("/refresh")
async def set_refresh_cookies_view( async def set_refresh_cookies_view(
request: Request, request: Request,
user: User | None = Depends(get_manager),
auto_refresh_enabled: Annotated[bool, Form()] = False, auto_refresh_enabled: Annotated[bool, Form()] = False,
auto_refresh_seconds: Annotated[int, Form()] = 15, auto_refresh_seconds: Annotated[int, Form()] = 15,
): ):

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 { h2 {
margin-bottom: calc(var(--typography-spacing-vertical) * 0.5); margin-bottom: calc(var(--pico-typography-spacing-vertical) * 0.5);
} }
.grid-index { .grid-index {
@ -26,10 +26,12 @@ h2 {
.grid-index article { .grid-index article {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; 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 { .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"] { label[for="select-status"] {

View file

@ -32,6 +32,10 @@
</li> </li>
</ul> </ul>
</a> </a>
{% if request.url.remove_query_params('msg') != url_for('login_view') %}
<ul>
<details class="dropdown">
<summary autofocus>Menu</summary>
<ul> <ul>
<li> <li>
<a href="{{ url_for('get_severity_counts_view') }}" <a href="{{ url_for('get_severity_counts_view') }}"
@ -39,24 +43,41 @@
role="button"> role="button">
Dashboard Dashboard
</a> </a>
</li>
<li>
<a href="{{ url_for('get_domains_view') }}" <a href="{{ url_for('get_domains_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_domains_view') }}" class="outline {{ 'contrast' if request.url == url_for('get_domains_view') }}"
role="button"> role="button">
Domains Domains
</a> </a>
</li>
<li>
<a href="{{ url_for('get_agents_view') }}" <a href="{{ url_for('get_agents_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}" class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}"
role="button"> role="button">
Agents Agents
</a> </a>
</li>
<li>
<a href="#" <a href="#"
id="reschedule-all" id="reschedule-all"
class="outline" class="outline"
title="Reschedule non-ok checks as soon as possible"> 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> </a>
</li> </li>
</ul> </ul>
</details>
</ul>
{% endif %}
</nav> </nav>
</header> </header>
<main class="container"> <main class="container">
@ -74,7 +95,7 @@
<a href="https://argos-monitoring.framasoft.org/">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/-/blob/main/LICENSE">AGPLv3</a>
(<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

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

@ -22,5 +22,4 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endblock content %} {% endblock content %}

View file

@ -16,12 +16,6 @@ server {
rewrite ^ https://$http_host$request_uri? permanent; 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 / { location / {
include proxy_params; include proxy_params;
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;

View file

@ -26,9 +26,9 @@ Options:
--help Show this message and exit. --help Show this message and exit.
Commands: Commands:
agent Get and run tasks to the provided server. agent Get and run tasks for the provided server.
server server Commands for managing server, servers configuration and users
version version Prints Argos version and exits
``` ```
<!--[[[end]]] <!--[[[end]]]
@ -43,7 +43,7 @@ Commands:
```man ```man
Usage: argos agent [OPTIONS] SERVER_URL AUTH 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" Usage: argos agent https://argos.example.org "auth-token-here"
@ -73,16 +73,19 @@ Options:
```man ```man
Usage: argos server [OPTIONS] COMMAND [ARGS]... Usage: argos server [OPTIONS] COMMAND [ARGS]...
Commands for managing server, servers configuration and users
Options: Options:
--help Show this message and exit. --help Show this message and exit.
Commands: Commands:
cleandb Clean the database (to run routinely) cleandb Clean the database (to run routinely)
generate-config Output an example config file. generate-config Output a self-documented example config file.
generate-token Generate a token for agents generate-token Generate a token for agents
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!)
user User management
watch-agents Watch agents (to run routinely) watch-agents Watch agents (to run routinely)
``` ```
@ -221,7 +224,29 @@ Options:
<!--[[[end]]] <!--[[[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 .. [[[cog
@ -241,3 +266,202 @@ Options:
<!--[[[end]]] <!--[[[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

@ -2,8 +2,7 @@
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.
Here is a simple configuration file: 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

@ -1,13 +1,6 @@
# Using Nginx as reverse proxy # Using Nginx as reverse proxy
As Argos has no authentication mechanism for the front-end, you need to protect some routes with HTTP authentication. Here is a example for Nginx configuration:
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:
```{literalinclude} ../../conf/nginx.conf ```{literalinclude} ../../conf/nginx.conf
--- ---
caption: /etc/nginx/sites-available/argos.example.org caption: /etc/nginx/sites-available/argos.example.org

View file

@ -78,7 +78,7 @@ argos server generate-config > /etc/argos/config.yaml
chmod 600 /etc/argos/config.yaml chmod 600 /etc/argos/config.yaml
``` ```
Please note that the only supported database engines are SQLite for development and PostgreSQL for production. Please note that the only supported database engines are SQLite for development and [PostgreSQL](postgresql.md) for production.
## Apply migrations to database ## Apply migrations to database
@ -98,6 +98,23 @@ Populate the database with the tasks:
argos server reload-config argos server reload-config
``` ```
## Generating a token
The agent needs an authentication token to be able to communicate with the server.
You can generate an authentication token with the following command:
```bash
argos server generate-token
```
Add the token in the configuration file, in the following setting:
```yaml
service:
secrets:
- "auth-token"
```
## Starting the server ## Starting the server
Then you can start the server: Then you can start the server:
@ -142,23 +159,6 @@ See <https://fastapi.tiangolo.com/deployment/manually/#asgi-servers> (but Gunico
See [here](../deployment/systemd.md#server) for a systemd service example and [here](../deployment/nginx.md) for a nginx configuration example. See [here](../deployment/systemd.md#server) for a systemd service example and [here](../deployment/nginx.md) for a nginx configuration example.
## Generating a token
The agent needs an authentication token to be able to communicate with the server.
You can generate an authentication token with the following command:
```bash
argos server generate-token
```
Add the token in the configuration file, in the following setting:
```yaml
service:
secrets:
- "auth-token"
```
## Running the agent ## Running the agent
You can run the agent on the same machine as the server, or on a different machine. You can run the agent on the same machine as the server, or on a different machine.
@ -170,7 +170,7 @@ argos agent http://localhost:8000 "auth-token"
## Cleaning the database ## 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: Here is a crontab example, which will clean the db each hour:
@ -182,7 +182,7 @@ Here is a crontab example, which will clean the db each hour:
## Watch the agents ## Watch the agents
In order to be sure that agents are up and communicate with the server, you can run periodically the `argos server watch-agents` command. 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: Here is a crontab example, which will check the agents every 5 minutes:

View file

@ -14,7 +14,7 @@ python3 -m venv venv
source venv/bin/activate source venv/bin/activate
pip install argos-monitoring pip install argos-monitoring
argos server generate-config | argos server generate-config |
sed -e "s@production@test@" \ sed -e "s@production@dev@" \
-e "s@url: .postgresql.*@url: \"sqlite:////tmp/argos.db\"@" > argos-config.yaml -e "s@url: .postgresql.*@url: \"sqlite:////tmp/argos.db\"@" > argos-config.yaml
argos server migrate argos server migrate
ARGOS_TOKEN=$(argos server generate-token) ARGOS_TOKEN=$(argos server generate-token)
@ -149,7 +149,10 @@ systemctl status argos-server.service argos-agent.service
If all works well, you have to put some cron tasks in `argos` crontab: If all works well, you have to put some cron tasks in `argos` crontab:
```bash ```bash
echo -e "*/10 * * * * /opt/argos/venv/bin/argos server cleandb --max-lock-seconds 120 --max-results 1200\n*/10 * * * * /opt/argos/venv/bin/argos server watch-agents --time-without-agent 10" | crontab -u argos - 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. See the [this page](../deployment/nginx.md) for using Nginx as reverse proxy.

View file

@ -22,10 +22,13 @@ classifiers = [
dependencies = [ dependencies = [
"alembic>=1.13.0,<1.14", "alembic>=1.13.0,<1.14",
"bcrypt>=4.1.3,<5",
"click>=8.1,<9", "click>=8.1,<9",
"fastapi>=0.103,<0.104", "fastapi>=0.103,<0.104",
"fastapi-login>=1.10.0,<2",
"httpx>=0.25,<1", "httpx>=0.25,<1",
"Jinja2>=3.0,<4", "Jinja2>=3.0,<4",
"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",
"pydantic-settings>=2.0,<3", "pydantic-settings>=2.0,<3",
@ -96,3 +99,7 @@ testpaths = [
"argos" "argos"
] ]
pythonpath = "." 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

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

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 import schemas
from argos.server import queries from argos.server import queries
from argos.server.models import Result, Task from argos.server.models import Result, Task, User
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remove_old_results(db, ten_tasks): async def test_remove_old_results(db, ten_tasks): # pylint: disable-msg=redefined-outer-name
for task in ten_tasks: for _task in ten_tasks:
for i in range(5): for _ in range(5):
result = Result( result = Result(
submitted_at=datetime.now(), submitted_at=datetime.now(),
status="success", status="success",
context={"foo": "bar"}, context={"foo": "bar"},
task=task, task=_task,
agent_id="test", agent_id="test",
severity="ok", severity="ok",
) )
@ -28,8 +28,8 @@ async def test_remove_old_results(db, ten_tasks):
deleted = await queries.remove_old_results(db, 2) deleted = await queries.remove_old_results(db, 2)
assert deleted == 30 assert deleted == 30
assert db.query(Result).count() == 20 assert db.query(Result).count() == 20
for task in ten_tasks: for _task in ten_tasks:
assert db.query(Result).filter(Result.task == task).count() == 2 assert db.query(Result).filter(Result.task == _task).count() == 2
@pytest.mark.asyncio @pytest.mark.asyncio
@ -40,7 +40,7 @@ async def test_remove_old_results_with_empty_db(db):
@pytest.mark.asyncio @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 assert db.query(Task).count() == 20
released = await queries.release_old_locks(db, 10) released = await queries.release_old_locks(db, 10)
assert released == 10 assert released == 10
@ -54,9 +54,9 @@ async def test_release_old_locks_with_empty_db(db):
@pytest.mark.asyncio @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 # 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( website = schemas.config.Website(
domain="https://example.org", domain="https://example.org",
paths=[ paths=[
@ -79,7 +79,9 @@ async def test_update_from_config_with_duplicate_tasks(db, empty_config):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_from_config_db_can_remove_duplicates_and_old_tasks( 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 # Add a duplicate in the db
same_task = Task( same_task = Task(
@ -96,10 +98,11 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
website = schemas.config.Website( website = schemas.config.Website(
domain=task.domain, domain=task.domain,
paths=[ paths=[
dict( {
path="https://another-example.com", checks=[{task.check: task.expected}] "path": "https://another-example.com",
), "checks": [{task.check: task.expected}],
dict(path=task.url, checks=[{task.check: task.expected}]), },
{"path": task.url, "checks": [{task.check: task.expected}]},
], ],
) )
empty_config.websites = [website] 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( website = schemas.config.Website(
domain=task.domain, domain=task.domain,
paths=[ 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] empty_config.websites = [website]
@ -122,14 +126,12 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
@pytest.mark.asyncio @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 assert db.query(Task).count() == 1
website = schemas.config.Website( website = schemas.config.Website(
domain=task.domain, domain=task.domain,
paths=[ paths=[{"path": task.url, "checks": [{task.check: task.expected}]}],
dict(path=task.url, checks=[{task.check: task.expected}]),
],
) )
empty_config.websites = [website] 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 @pytest.mark.asyncio
async def test_reschedule_all( 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).count() == 40
assert db.query(Task).filter(Task.severity == "unknown").count() == 10 assert db.query(Task).filter(Task.severity == "unknown").count() == 10
@ -154,18 +160,65 @@ async def test_reschedule_all(
assert db.query(Task).filter(Task.next_run <= one_hour_ago).count() == 30 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 @pytest.fixture
def task(db): def task(db):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="https://www.example.com", domain="https://www.example.com",
check="body-contains", check="body-contains",
expected="foo", expected="foo",
frequency=1, frequency=1,
) )
db.add(task) db.add(_task)
db.commit() db.commit()
return task return _task
@pytest.fixture @pytest.fixture
@ -173,6 +226,7 @@ def empty_config():
return schemas.config.Config( return schemas.config.Config(
general=schemas.config.General( general=schemas.config.General(
db=schemas.config.DbSettings(url="sqlite:////tmp/test-argos.db"), db=schemas.config.DbSettings(url="sqlite:////tmp/test-argos.db"),
cookie_secret="foo-bar-baz",
frequency="1m", frequency="1m",
alerts=schemas.config.Alert( alerts=schemas.config.Alert(
ok=["", ""], ok=["", ""],
@ -192,9 +246,9 @@ def empty_config():
@pytest.fixture @pytest.fixture
def ten_results(db, task): def ten_results(db, task): # pylint: disable-msg=redefined-outer-name
results = [] results = []
for i in range(10): for _ in range(10):
result = Result( result = Result(
submitted_at=datetime.now(), submitted_at=datetime.now(),
status="success", status="success",
@ -213,8 +267,8 @@ def ten_results(db, task):
def ten_locked_tasks(db): def ten_locked_tasks(db):
a_minute_ago = datetime.now() - timedelta(minutes=1) a_minute_ago = datetime.now() - timedelta(minutes=1)
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -223,8 +277,8 @@ def ten_locked_tasks(db):
selected_by="test", selected_by="test",
selected_at=a_minute_ago, selected_at=a_minute_ago,
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@ -233,8 +287,8 @@ def ten_locked_tasks(db):
def ten_tasks(db): def ten_tasks(db):
now = datetime.now() now = datetime.now()
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -243,8 +297,8 @@ def ten_tasks(db):
selected_by="test", selected_by="test",
selected_at=now, selected_at=now,
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@ -253,8 +307,8 @@ def ten_tasks(db):
def ten_warning_tasks(db): def ten_warning_tasks(db):
now = datetime.now() now = datetime.now()
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -263,8 +317,8 @@ def ten_warning_tasks(db):
next_run=now, next_run=now,
severity="warning", severity="warning",
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@ -273,8 +327,8 @@ def ten_warning_tasks(db):
def ten_critical_tasks(db): def ten_critical_tasks(db):
now = datetime.now() now = datetime.now()
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -283,8 +337,8 @@ def ten_critical_tasks(db):
next_run=now, next_run=now,
severity="critical", severity="critical",
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@ -293,8 +347,8 @@ def ten_critical_tasks(db):
def ten_ok_tasks(db): def ten_ok_tasks(db):
now = datetime.now() now = datetime.now()
tasks = [] tasks = []
for i in range(10): for _ in range(10):
task = Task( _task = Task(
url="https://www.example.com", url="https://www.example.com",
domain="example.com", domain="example.com",
check="body-contains", check="body-contains",
@ -303,7 +357,19 @@ def ten_ok_tasks(db):
next_run=now, next_run=now,
severity="ok", severity="ok",
) )
db.add(task) db.add(_task)
tasks.append(task) tasks.append(_task)
db.commit() db.commit()
return tasks return tasks
@pytest.fixture
def user(db):
_user = User(
username="jane",
password="doe",
disabled=False,
)
db.add(_user)
db.commit()
return _user