mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 18:02:41 +02:00
🔀 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:
commit
98f2ce6f63
28 changed files with 1144 additions and 223 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -61,11 +61,17 @@ def cli():
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def server():
|
def server():
|
||||||
pass
|
"""Commands for managing server, server’s 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"""
|
||||||
|
# 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")
|
@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:
|
||||||
|
|
|
@ -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
|
||||||
|
# You’ll need to configure mail and gotify below to be able to use them here.
|
||||||
alerts:
|
alerts:
|
||||||
ok:
|
ok:
|
||||||
- local
|
- local
|
||||||
|
@ -22,23 +29,26 @@ general:
|
||||||
- local
|
- local
|
||||||
unknown:
|
unknown:
|
||||||
- local
|
- local
|
||||||
# mail:
|
# Mail configuration is quite straight-forward
|
||||||
# mailfrom: no-reply@example.org
|
# mail:
|
||||||
# host: 127.0.0.1
|
# mailfrom: no-reply@example.org
|
||||||
# port: 25
|
# host: 127.0.0.1
|
||||||
# ssl: False
|
# port: 25
|
||||||
# starttls: False
|
# ssl: False
|
||||||
# auth:
|
# starttls: False
|
||||||
# login: foo
|
# auth:
|
||||||
# password: bar
|
# login: foo
|
||||||
# addresses:
|
# password: bar
|
||||||
# - foo@admin.example.org
|
# addresses:
|
||||||
# - bar@admin.example.org
|
# - foo@admin.example.org
|
||||||
# gotify:
|
# - bar@admin.example.org
|
||||||
# - url: https://example.org
|
# Create an app on your Gotify server and put its token here
|
||||||
# tokens:
|
# See https://gotify.net/ for details about Gotify
|
||||||
# - foo
|
# gotify:
|
||||||
# - bar
|
# - url: https://example.org
|
||||||
|
# tokens:
|
||||||
|
# - foo
|
||||||
|
# - bar
|
||||||
|
|
||||||
service:
|
service:
|
||||||
secrets:
|
secrets:
|
||||||
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
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 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()
|
||||||
|
|
|
@ -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)
|
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()
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
):
|
):
|
||||||
|
|
|
@ -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,
|
||||||
):
|
):
|
||||||
|
|
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 {
|
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"] {
|
||||||
|
|
|
@ -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
|
||||||
|
|
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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
236
docs/cli.md
236
docs/cli.md
|
@ -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, server’s 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, server’s 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 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]]]
|
||||||
|
-->
|
||||||
|
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
Argos uses a simple YAML configuration file to define the server’s configuration, the websites to monitor and the checks to run on these websites.
|
Argos uses a simple YAML configuration file to define the server’s 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
|
||||||
---
|
---
|
||||||
|
|
|
@ -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-end’s 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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
@ -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
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 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
|
||||||
|
|
Loading…
Reference in a new issue