mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 18:02:41 +02:00
556 lines
16 KiB
Python
556 lines
16 KiB
Python
import asyncio
|
||
import os
|
||
from functools import wraps
|
||
from pathlib import Path
|
||
from sys import exit as sysexit
|
||
from uuid import uuid4
|
||
|
||
import click
|
||
import uvicorn
|
||
from alembic import command
|
||
from alembic.config import Config
|
||
|
||
from argos_monitoring import logging
|
||
from argos_monitoring import VERSION
|
||
from argos_monitoring.agent import ArgosAgent
|
||
|
||
|
||
async def get_db():
|
||
from argos_monitoring.server.main import (
|
||
connect_to_db,
|
||
get_application,
|
||
setup_database,
|
||
)
|
||
|
||
app = get_application()
|
||
setup_database(app)
|
||
return await connect_to_db(app)
|
||
|
||
|
||
def coroutine(f):
|
||
"""Decorator to enable async functions in click"""
|
||
|
||
@wraps(f)
|
||
def wrapper(*args, **kwargs):
|
||
return asyncio.run(f(*args, **kwargs))
|
||
|
||
return wrapper
|
||
|
||
|
||
def validate_config_access(ctx, param, value):
|
||
for file in list(
|
||
dict.fromkeys([value, "argos-config.yaml", "/etc/argos/config.yaml"])
|
||
):
|
||
path = Path(file)
|
||
|
||
if path.is_file() and os.access(path, os.R_OK):
|
||
return file
|
||
|
||
if value == "argos-config.yaml":
|
||
raise click.BadParameter(
|
||
f"the file {value} does not exists or is not reachable, "
|
||
"nor does /etc/argos/config.yaml."
|
||
)
|
||
|
||
raise click.BadParameter(
|
||
f"the file {value} does not exists or is not reachable, "
|
||
"nor does argos-config.yaml or /etc/argos/config.yaml."
|
||
)
|
||
|
||
|
||
@click.group()
|
||
def cli():
|
||
pass
|
||
|
||
|
||
@cli.group()
|
||
def server():
|
||
"""Commands for managing server, server’s configuration and users"""
|
||
|
||
|
||
@server.group()
|
||
def user():
|
||
"""User management"""
|
||
|
||
|
||
@cli.command()
|
||
def version():
|
||
"""Prints Argos’ version and exits"""
|
||
click.echo(VERSION)
|
||
|
||
|
||
@cli.command()
|
||
@click.argument("server_url", envvar="ARGOS_AGENT_SERVER_URL")
|
||
@click.argument("auth", envvar="ARGOS_AGENT_TOKEN")
|
||
@click.option(
|
||
"--max-tasks",
|
||
default=10,
|
||
help="Number of concurrent tasks this agent can run",
|
||
)
|
||
@click.option(
|
||
"--wait-time",
|
||
default=10,
|
||
help="Waiting time between two polls on the server (seconds)",
|
||
)
|
||
@click.option(
|
||
"--log-level",
|
||
default="INFO",
|
||
type=click.Choice(logging.LOG_LEVELS, case_sensitive=False),
|
||
)
|
||
def agent(server_url, auth, max_tasks, wait_time, log_level):
|
||
"""Get and run tasks for the provided server. Will wait for new tasks.
|
||
|
||
Usage: argos-monitoring agent https://argos.example.org "auth-token-here"
|
||
|
||
Alternatively, you can use the following environment variables to avoid passing
|
||
arguments to the agent on the command line:
|
||
|
||
\b
|
||
ARGOS_AGENT_SERVER_URL=https://argos.example.org
|
||
ARGOS_AGENT_TOKEN=auth-token-here
|
||
"""
|
||
click.echo("Starting argos agent. Will retry forever.")
|
||
from argos_monitoring.logging import logger
|
||
|
||
logger.setLevel(log_level)
|
||
agent_ = ArgosAgent(server_url, auth, max_tasks, wait_time)
|
||
asyncio.run(agent_.run())
|
||
|
||
|
||
@server.command()
|
||
@click.option("--host", default="127.0.0.1", help="Host to bind")
|
||
@click.option("--port", default=8000, type=int, help="Port to bind")
|
||
@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. "
|
||
"Default value: argos-config.yaml and /etc/argos/config.yaml as fallback.",
|
||
envvar="ARGOS_YAML_FILE",
|
||
callback=validate_config_access,
|
||
)
|
||
@click.option("--reload", is_flag=True, help="Enable hot reloading")
|
||
def start(host, port, config, reload):
|
||
"""Starts the server (use only for testing or development!)
|
||
|
||
See https://argos-monitoring.framasoft.org/deployment/systemd.html#server
|
||
for advices on how to start the server for production.
|
||
"""
|
||
os.environ["ARGOS_YAML_FILE"] = config
|
||
uvicorn.run("argos_monitoring.server:app", host=host, port=port, reload=reload)
|
||
|
||
|
||
def validate_max_lock_seconds(ctx, param, value):
|
||
if value <= 60:
|
||
raise click.BadParameter("Should be strictly higher than 60")
|
||
return value
|
||
|
||
|
||
def validate_max_results(ctx, param, value):
|
||
if value <= 0:
|
||
raise click.BadParameter("Should be a positive integer")
|
||
return value
|
||
|
||
|
||
@server.command()
|
||
@click.option(
|
||
"--max-results",
|
||
default=100,
|
||
help="Number of results per task to keep",
|
||
callback=validate_max_results,
|
||
)
|
||
@click.option(
|
||
"--max-lock-seconds",
|
||
default=100,
|
||
help=(
|
||
"The number of seconds after which a lock is "
|
||
"considered stale, must be higher than 60 "
|
||
"(the checks have a timeout value of 60 seconds)"
|
||
),
|
||
callback=validate_max_lock_seconds,
|
||
)
|
||
@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. "
|
||
"Default value: argos-config.yaml and /etc/argos/config.yaml as fallback.",
|
||
envvar="ARGOS_YAML_FILE",
|
||
callback=validate_config_access,
|
||
)
|
||
@coroutine
|
||
async def cleandb(max_results, max_lock_seconds, config):
|
||
"""Clean the database (to run routinely)
|
||
|
||
\b
|
||
- Removes old results from the database.
|
||
- Removes locks from tasks that have been locked for too long.
|
||
"""
|
||
# 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_monitoring.server import queries
|
||
|
||
db = await get_db()
|
||
removed = await queries.remove_old_results(db, max_results)
|
||
updated = await queries.release_old_locks(db, max_lock_seconds)
|
||
|
||
click.echo(f"{removed} results removed")
|
||
click.echo(f"{updated} locks released")
|
||
|
||
|
||
@server.command()
|
||
@click.option(
|
||
"--time-without-agent",
|
||
default=5,
|
||
help="Time without seeing an agent after which a warning will be issued, in minutes. "
|
||
"Default is 5 minutes.",
|
||
callback=validate_max_results,
|
||
)
|
||
@click.option(
|
||
"--config",
|
||
default="argos-config.yaml",
|
||
help="Path of the configuration file. "
|
||
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
|
||
envvar="ARGOS_YAML_FILE",
|
||
callback=validate_config_access,
|
||
)
|
||
@coroutine
|
||
async def watch_agents(time_without_agent, config):
|
||
"""Watch agents (to run routinely)
|
||
|
||
Issues a warning if no agent has been seen by the server for a given time.
|
||
"""
|
||
# 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_monitoring.server import queries
|
||
|
||
db = await get_db()
|
||
agents = await queries.get_recent_agents_count(db, time_without_agent)
|
||
if agents == 0:
|
||
click.echo(f"No agent has been seen in the last {time_without_agent} minutes.")
|
||
sysexit(1)
|
||
|
||
|
||
@server.command(short_help="Load or reload tasks’ configuration")
|
||
@click.option(
|
||
"--config",
|
||
default="argos-config.yaml",
|
||
help="Path of the configuration file. "
|
||
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead. "
|
||
"Default value: argos-config.yaml and /etc/argos/config.yaml as fallback.",
|
||
envvar="ARGOS_YAML_FILE",
|
||
callback=validate_config_access,
|
||
)
|
||
@coroutine
|
||
async def reload_config(config):
|
||
"""Read tasks’ configuration and add/delete tasks in database if needed"""
|
||
# 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_monitoring.server import queries
|
||
from argos_monitoring.server.main import read_config
|
||
|
||
_config = read_config(config)
|
||
|
||
db = await get_db()
|
||
changed = await queries.update_from_config(db, _config)
|
||
|
||
click.echo(f"{changed['added']} tasks added")
|
||
click.echo(f"{changed['vanished']} tasks deleted")
|
||
|
||
|
||
@server.command()
|
||
@click.option(
|
||
"--config",
|
||
default="argos-config.yaml",
|
||
help="Path of the configuration file. "
|
||
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead. "
|
||
"Default value: argos-config.yaml and /etc/argos/config.yaml as fallback.",
|
||
envvar="ARGOS_YAML_FILE",
|
||
callback=validate_config_access,
|
||
)
|
||
@coroutine
|
||
async def migrate(config):
|
||
"""Run database migrations"""
|
||
# 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_monitoring.server.settings import read_yaml_config
|
||
|
||
settings = read_yaml_config(config)
|
||
|
||
current_dir = Path(__file__).resolve().parent
|
||
alembic_cfg = Config(current_dir / "server" / "migrations" / "alembic.ini")
|
||
alembic_cfg.set_main_option("sqlalchemy.url", str(settings.general.db.url))
|
||
command.upgrade(alembic_cfg, "head")
|
||
|
||
|
||
@user.command()
|
||
@click.option(
|
||
"--config",
|
||
default="argos-config.yaml",
|
||
help="Path of the configuration file. "
|
||
"If ARGOS_YAML_FILE environment variable is set, its value will be used instead.",
|
||
envvar="ARGOS_YAML_FILE",
|
||
callback=validate_config_access,
|
||
)
|
||
@click.option("--name", prompt=True, help="Name of the user to create.")
|
||
@click.password_option()
|
||
@coroutine
|
||
async def add(config, name, password):
|
||
"""Add new user"""
|
||
# 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_monitoring.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_monitoring.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_monitoring.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_monitoring.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_monitoring.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_monitoring.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_monitoring.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():
|
||
"""Generate a token, which can be used as an agent’s authentication token.
|
||
|
||
It’s actually an UUID
|
||
"""
|
||
click.echo(uuid4())
|
||
|
||
|
||
@server.command()
|
||
@coroutine
|
||
async def generate_config():
|
||
"""Output a self-documented example config file.
|
||
|
||
\b
|
||
Redirect the output to a file to save it:
|
||
argos-monitoring server generate-config > /etc/argos/config.yaml
|
||
"""
|
||
config_example = Path(__file__).resolve().parent / "config-example.yaml"
|
||
with config_example.open("r", encoding="utf-8") as f:
|
||
print(f.read())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
cli()
|