mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 09:52:38 +02:00
✨ — Add built-in authentication for human interface
Built-in user management is provided too
This commit is contained in:
parent
29d839dc4d
commit
09a858794d
25 changed files with 1032 additions and 152 deletions
|
@ -11,6 +11,7 @@
|
|||
- 💥 — 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
|
||||
|
||||
|
|
|
@ -64,6 +64,11 @@ def server():
|
|||
pass
|
||||
|
||||
|
||||
@server.group()
|
||||
def user():
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
def version():
|
||||
click.echo(VERSION)
|
||||
|
@ -200,7 +205,7 @@ async def cleandb(max_results, max_lock_seconds, config):
|
|||
)
|
||||
@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.",
|
||||
envvar="ARGOS_YAML_FILE",
|
||||
|
@ -281,6 +286,243 @@ async def migrate(config):
|
|||
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"""
|
||||
# It’s 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 user’s password"""
|
||||
# It’s 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 user’s password"""
|
||||
# It’s 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"""
|
||||
# It’s 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"""
|
||||
# It’s 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"""
|
||||
# It’s 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"""
|
||||
# It’s 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():
|
||||
|
|
|
@ -10,6 +10,9 @@ general:
|
|||
# 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"
|
||||
frequency: "1m" # Run checks every minute.
|
||||
# Which way do you want to be warned when a check goes to that severity?
|
||||
# "local" emits a message in the server log
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import logging
|
||||
|
||||
|
||||
logging.getLogger("passlib").setLevel(logging.ERROR)
|
||||
|
||||
|
||||
LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
|
||||
# Print level before message
|
||||
|
|
|
@ -153,6 +153,7 @@ class DbSettings(BaseModel):
|
|||
class General(BaseModel):
|
||||
"""Frequency for the checks and alerts"""
|
||||
|
||||
cookie_secret: str
|
||||
frequency: int
|
||||
db: DbSettings
|
||||
env: Environment = "production"
|
||||
|
|
13
argos/server/exceptions.py
Normal file
13
argos/server/exceptions.py
Normal 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"))
|
|
@ -1,36 +1,42 @@
|
|||
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, 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"""
|
||||
appli = FastAPI()
|
||||
appli = FastAPI(lifespan=lifespan)
|
||||
config_file = os.environ["ARGOS_YAML_FILE"]
|
||||
|
||||
config = read_config(config_file)
|
||||
|
||||
# Config is the argos config object (built from yaml)
|
||||
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.views)
|
||||
|
||||
|
@ -40,44 +46,11 @@ def get_application() -> FastAPI:
|
|||
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):
|
||||
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(yaml_file):
|
||||
try:
|
||||
config = read_yaml_config(yaml_file)
|
||||
|
@ -119,4 +92,36 @@ def setup_database(appli):
|
|||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def create_manager(cookie_secret):
|
||||
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()
|
||||
|
|
|
@ -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")
|
|
@ -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()
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
"""Functions to ease SQL queries management"""
|
||||
from datetime import datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from typing import List, Union
|
||||
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,7 +67,7 @@ async def create_result(db: Session, agent_result: schemas.AgentResult, agent_id
|
|||
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)
|
||||
if selected is not None:
|
||||
if selected:
|
||||
|
|
|
@ -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)
|
||||
):
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
"""Web interface for humans"""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from functools import cmp_to_key
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
@ -7,14 +8,16 @@ 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()
|
||||
|
||||
|
@ -23,9 +26,74 @@ 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,
|
||||
):
|
||||
|
|
7
argos/server/static/pico.min.css
vendored
7
argos/server/static/pico.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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"] {
|
||||
|
|
|
@ -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">
|
||||
|
@ -74,7 +95,7 @@
|
|||
<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
|
||||
|
|
25
argos/server/templates/login.html
Normal file
25
argos/server/templates/login.html
Normal 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 %}
|
|
@ -22,5 +22,4 @@
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock content %}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
198
docs/cli.md
198
docs/cli.md
|
@ -83,6 +83,7 @@ Commands:
|
|||
migrate Run database migrations
|
||||
reload-config Load or reload tasks’ configuration
|
||||
start Starts the server (use only for testing or development!)
|
||||
user
|
||||
watch-agents Watch agents (to run routinely)
|
||||
```
|
||||
|
||||
|
@ -241,3 +242,200 @@ 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]...
|
||||
|
||||
Options:
|
||||
--help Show this message and exit.
|
||||
|
||||
Commands:
|
||||
add Add new user
|
||||
change-password Change user’s password
|
||||
delete Delete user
|
||||
disable Disable user
|
||||
enable Enable user
|
||||
show List all users
|
||||
verify-password Test user’s 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 user’s 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 user’s 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]]]
|
||||
-->
|
||||
|
|
|
@ -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-end’s routes:
|
||||
Here is a example for Nginx configuration:
|
||||
```{literalinclude} ../../conf/nginx.conf
|
||||
---
|
||||
caption: /etc/nginx/sites-available/argos.example.org
|
||||
|
|
|
@ -22,10 +22,13 @@ classifiers = [
|
|||
|
||||
dependencies = [
|
||||
"alembic>=1.13.0,<1.14",
|
||||
"bcrypt>=4.1.3,<5",
|
||||
"click>=8.1,<9",
|
||||
"fastapi>=0.103,<0.104",
|
||||
"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",
|
||||
|
@ -96,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",
|
||||
]
|
||||
|
|
|
@ -3,6 +3,7 @@ general:
|
|||
# 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:
|
||||
|
|
150
tests/test_cli.py
Normal file
150
tests/test_cli.py
Normal 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"
|
|
@ -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,18 +160,65 @@ 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
|
||||
|
@ -173,6 +226,7 @@ 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=["", ""],
|
||||
|
@ -192,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",
|
||||
|
@ -213,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",
|
||||
|
@ -223,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
|
||||
|
||||
|
@ -233,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",
|
||||
|
@ -243,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
|
||||
|
||||
|
@ -253,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",
|
||||
|
@ -263,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
|
||||
|
||||
|
@ -273,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",
|
||||
|
@ -283,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
|
||||
|
||||
|
@ -293,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",
|
||||
|
@ -303,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
|
||||
|
|
Loading…
Reference in a new issue