mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 18:02:41 +02:00
🔀 Merge branch 'develop'
This commit is contained in:
commit
3a3c5852d0
7 changed files with 360 additions and 20 deletions
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- 💄 — Improve email and gotify notifications
|
||||||
|
- ✨ — Add command to test gotify configuration
|
||||||
|
- ✨ — Add nagios command to use as a Nagios probe
|
||||||
|
- ✨ — Add Apprise as notification way (#50)
|
||||||
|
|
||||||
## 0.3.1
|
## 0.3.1
|
||||||
|
|
||||||
Date: 2024-09-02
|
Date: 2024-09-02
|
||||||
|
|
|
@ -607,12 +607,196 @@ async def test_mail(config, domain, severity):
|
||||||
notify_by_mail(
|
notify_by_mail(
|
||||||
result,
|
result,
|
||||||
task,
|
task,
|
||||||
severity="SEVERITY",
|
severity=severity,
|
||||||
old_severity="OLD SEVERITY",
|
old_severity="OLD SEVERITY",
|
||||||
config=conf.general.mail,
|
config=conf.general.mail,
|
||||||
request=_FalseRequest(),
|
request=_FalseRequest(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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.",
|
||||||
|
envvar="ARGOS_YAML_FILE",
|
||||||
|
callback=validate_config_access,
|
||||||
|
)
|
||||||
|
@click.option("--domain", help="Domain for the notification", default="example.org")
|
||||||
|
@click.option("--severity", help="Severity", default="CRITICAL")
|
||||||
|
@coroutine
|
||||||
|
async def test_gotify(config, domain, severity):
|
||||||
|
"""Send a test gotify notification"""
|
||||||
|
os.environ["ARGOS_YAML_FILE"] = config
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from argos.logging import set_log_level
|
||||||
|
from argos.server.alerting import notify_with_gotify
|
||||||
|
from argos.server.main import read_config
|
||||||
|
from argos.server.models import Result, Task
|
||||||
|
|
||||||
|
conf = read_config(config)
|
||||||
|
|
||||||
|
if not conf.general.gotify:
|
||||||
|
click.echo("Gotify notifications are not configured, cannot test", err=True)
|
||||||
|
sysexit(1)
|
||||||
|
else:
|
||||||
|
now = datetime.now()
|
||||||
|
task = Task(
|
||||||
|
url=f"https://{domain}",
|
||||||
|
domain=domain,
|
||||||
|
check="body-contains",
|
||||||
|
expected="foo",
|
||||||
|
frequency=1,
|
||||||
|
selected_by="test",
|
||||||
|
selected_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Result(
|
||||||
|
submitted_at=now,
|
||||||
|
status="success",
|
||||||
|
context={"foo": "bar"},
|
||||||
|
task=task,
|
||||||
|
agent_id="test",
|
||||||
|
severity="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
class _FalseRequest:
|
||||||
|
def url_for(*args, **kwargs):
|
||||||
|
return "/url"
|
||||||
|
|
||||||
|
set_log_level("debug")
|
||||||
|
notify_with_gotify(
|
||||||
|
result,
|
||||||
|
task,
|
||||||
|
severity=severity,
|
||||||
|
old_severity="OLD SEVERITY",
|
||||||
|
config=conf.general.gotify,
|
||||||
|
request=_FalseRequest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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.",
|
||||||
|
envvar="ARGOS_YAML_FILE",
|
||||||
|
callback=validate_config_access,
|
||||||
|
)
|
||||||
|
@click.option("--domain", help="Domain for the notification", default="example.org")
|
||||||
|
@click.option("--severity", help="Severity", default="CRITICAL")
|
||||||
|
@click.option(
|
||||||
|
"--apprise-group", help="Apprise group for the notification", required=True
|
||||||
|
)
|
||||||
|
@coroutine
|
||||||
|
async def test_apprise(config, domain, severity, apprise_group):
|
||||||
|
"""Send a test apprise notification"""
|
||||||
|
os.environ["ARGOS_YAML_FILE"] = config
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from argos.logging import set_log_level
|
||||||
|
from argos.server.alerting import notify_with_apprise
|
||||||
|
from argos.server.main import read_config
|
||||||
|
from argos.server.models import Result, Task
|
||||||
|
|
||||||
|
conf = read_config(config)
|
||||||
|
|
||||||
|
if not conf.general.apprise:
|
||||||
|
click.echo("Apprise notifications are not configured, cannot test", err=True)
|
||||||
|
sysexit(1)
|
||||||
|
else:
|
||||||
|
now = datetime.now()
|
||||||
|
task = Task(
|
||||||
|
url=f"https://{domain}",
|
||||||
|
domain=domain,
|
||||||
|
check="body-contains",
|
||||||
|
expected="foo",
|
||||||
|
frequency=1,
|
||||||
|
selected_by="test",
|
||||||
|
selected_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Result(
|
||||||
|
submitted_at=now,
|
||||||
|
status="success",
|
||||||
|
context={"foo": "bar"},
|
||||||
|
task=task,
|
||||||
|
agent_id="test",
|
||||||
|
severity="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
class _FalseRequest:
|
||||||
|
def url_for(*args, **kwargs):
|
||||||
|
return "/url"
|
||||||
|
|
||||||
|
set_log_level("debug")
|
||||||
|
notify_with_apprise(
|
||||||
|
result,
|
||||||
|
task,
|
||||||
|
severity=severity,
|
||||||
|
old_severity="OLD SEVERITY",
|
||||||
|
group=conf.general.apprise[apprise_group],
|
||||||
|
request=_FalseRequest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@server.command(short_help="Nagios compatible severities report")
|
||||||
|
@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 nagios(config):
|
||||||
|
"""Output a report of current severities suitable for Nagios
|
||||||
|
with a Nagios compatible exit code"""
|
||||||
|
os.environ["ARGOS_YAML_FILE"] = config
|
||||||
|
|
||||||
|
# The imports are made here otherwise the agent will need server configuration files.
|
||||||
|
from argos.server import queries
|
||||||
|
|
||||||
|
exit_nb = 0
|
||||||
|
db = await get_db()
|
||||||
|
severities = await queries.get_severity_counts(db)
|
||||||
|
|
||||||
|
if severities["warning"] != 0:
|
||||||
|
exit_nb = 1
|
||||||
|
if severities["critical"] != 0:
|
||||||
|
exit_nb = 2
|
||||||
|
if severities["unknown"] != 0:
|
||||||
|
exit_nb = 2
|
||||||
|
|
||||||
|
stats = (
|
||||||
|
f"ok={severities['ok']}; warning={severities['warning']}; "
|
||||||
|
f"critical={severities['critical']}; unknown={severities['unknown']};"
|
||||||
|
)
|
||||||
|
|
||||||
|
if exit_nb == 0:
|
||||||
|
print("OK — All sites are ok|{stats}")
|
||||||
|
elif exit_nb == 1:
|
||||||
|
print(f"WARNING — {severities['warning']} sites are in warning state|{stats}")
|
||||||
|
elif severities["critical"] == 0:
|
||||||
|
print(f"UNKNOWN — {severities['unknown']} sites are in unknown state|{stats}")
|
||||||
|
elif severities["unknown"] == 0:
|
||||||
|
print(
|
||||||
|
f"CRITICAL — {severities['critical']} sites are in critical state|{stats}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"CRITICAL/UNKNOWN — {severities['critical']} sites are in critical state "
|
||||||
|
f"and {severities['unknown']} sites are in unknown state|{stats}"
|
||||||
|
)
|
||||||
|
|
||||||
|
sysexit(exit_nb)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
|
|
@ -23,7 +23,10 @@ general:
|
||||||
frequency: "1m"
|
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.
|
# You’ll need to configure mail, gotify or apprise below to be able to use
|
||||||
|
# them here.
|
||||||
|
# Use "apprise:john", "apprise:team" (with the quotes!) to use apprise
|
||||||
|
# notification groups.
|
||||||
alerts:
|
alerts:
|
||||||
ok:
|
ok:
|
||||||
- local
|
- local
|
||||||
|
@ -58,6 +61,17 @@ general:
|
||||||
# tokens:
|
# tokens:
|
||||||
# - foo
|
# - foo
|
||||||
# - bar
|
# - bar
|
||||||
|
# See https://github.com/caronc/apprise#productivity-based-notifications
|
||||||
|
# for apprise’s URLs syntax.
|
||||||
|
# You need to surround the URLs with quotes like in the examples below.
|
||||||
|
# Use "apprise:john", "apprise:team" (with the quotes!) in "alerts" settings.
|
||||||
|
# apprise:
|
||||||
|
# john:
|
||||||
|
# - "mastodon://access_key@hostname/@user"
|
||||||
|
# - "matrixs://token@hostname:port/?webhook=matrix"
|
||||||
|
# team:
|
||||||
|
# - "mmosts://user@hostname/authkey"
|
||||||
|
# - "nctalks://user:pass@host/RoomId1/RoomId2/RoomIdN"
|
||||||
|
|
||||||
service:
|
service:
|
||||||
secrets:
|
secrets:
|
||||||
|
|
|
@ -168,6 +168,7 @@ class General(BaseModel):
|
||||||
alerts: Alert
|
alerts: Alert
|
||||||
mail: Optional[Mail] = None
|
mail: Optional[Mail] = None
|
||||||
gotify: Optional[List[GotifyUrl]] = None
|
gotify: Optional[List[GotifyUrl]] = None
|
||||||
|
apprise: Optional[Dict[str, List[str]]] = None
|
||||||
|
|
||||||
@field_validator("frequency", mode="before")
|
@field_validator("frequency", mode="before")
|
||||||
def parse_frequency(cls, value):
|
def parse_frequency(cls, value):
|
||||||
|
|
|
@ -4,14 +4,24 @@ import smtplib
|
||||||
from typing import List
|
from typing import List
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import apprise
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from argos.checks.base import Severity
|
from argos.checks.base import Severity
|
||||||
from argos.logging import logger
|
from argos.logging import logger
|
||||||
from argos.schemas.config import Config, Mail, GotifyUrl
|
from argos.schemas.config import Config, Mail, GotifyUrl
|
||||||
|
|
||||||
# XXX Implement mail alerts https://framagit.org/framasoft/framaspace/argos/-/issues/15
|
|
||||||
# XXX Implement gotify alerts https://framagit.org/framasoft/framaspace/argos/-/issues/16
|
def get_icon_from_severity(severity: str) -> str:
|
||||||
|
icon = "❌"
|
||||||
|
if severity == Severity.OK:
|
||||||
|
icon = "✅"
|
||||||
|
elif severity == Severity.WARNING:
|
||||||
|
icon = "⚠️"
|
||||||
|
elif severity == Severity.UNKNOWN:
|
||||||
|
icon = "❔"
|
||||||
|
|
||||||
|
return icon
|
||||||
|
|
||||||
|
|
||||||
def handle_alert(config: Config, result, task, severity, old_severity, request):
|
def handle_alert(config: Config, result, task, severity, old_severity, request):
|
||||||
|
@ -39,12 +49,52 @@ def handle_alert(config: Config, result, task, severity, old_severity, request):
|
||||||
result, task, severity, old_severity, config.general.gotify, request
|
result, task, severity, old_severity, config.general.gotify, request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if config.general.apprise is not None:
|
||||||
|
for notif_way in getattr(config.general.alerts, severity):
|
||||||
|
if notif_way.startswith("apprise:"):
|
||||||
|
group = notif_way[8:]
|
||||||
|
notify_with_apprise(
|
||||||
|
result,
|
||||||
|
task,
|
||||||
|
severity,
|
||||||
|
old_severity,
|
||||||
|
config.general.apprise[group],
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_with_apprise(
|
||||||
|
result, task, severity: str, old_severity: str, group: List[str], request
|
||||||
|
) -> None:
|
||||||
|
logger.debug("Will send apprise notification")
|
||||||
|
|
||||||
|
apobj = apprise.Apprise()
|
||||||
|
for channel in group:
|
||||||
|
apobj.add(channel)
|
||||||
|
|
||||||
|
icon = get_icon_from_severity(severity)
|
||||||
|
title = f"[Argos] {icon} {urlparse(task.url).netloc}: status {severity}"
|
||||||
|
msg = f"""\
|
||||||
|
URL: {task.url}
|
||||||
|
Check: {task.check}
|
||||||
|
Status: {severity}
|
||||||
|
Time: {result.submitted_at}
|
||||||
|
Previous status: {old_severity}
|
||||||
|
|
||||||
|
See result on {request.url_for('get_result_view', result_id=result.id)}
|
||||||
|
|
||||||
|
See results of task on {request.url_for('get_task_results_view', task_id=task.id)}#{result.id}
|
||||||
|
"""
|
||||||
|
|
||||||
|
apobj.notify(title=title, body=msg)
|
||||||
|
|
||||||
|
|
||||||
def notify_by_mail(
|
def notify_by_mail(
|
||||||
result, task, severity: str, old_severity: str, config: Mail, request
|
result, task, severity: str, old_severity: str, config: Mail, request
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.debug("Will send mail notification")
|
logger.debug("Will send mail notification")
|
||||||
|
|
||||||
|
icon = get_icon_from_severity(severity)
|
||||||
msg = f"""\
|
msg = f"""\
|
||||||
URL: {task.url}
|
URL: {task.url}
|
||||||
Check: {task.check}
|
Check: {task.check}
|
||||||
|
@ -58,7 +108,7 @@ See results of task on {request.url_for('get_task_results_view', task_id=task.id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mail = f"""\
|
mail = f"""\
|
||||||
Subject: [Argos] {urlparse(task.url).netloc}: status {severity}
|
Subject: [Argos] {icon} {urlparse(task.url).netloc}: status {severity}
|
||||||
|
|
||||||
{msg}"""
|
{msg}"""
|
||||||
|
|
||||||
|
@ -92,29 +142,35 @@ def notify_with_gotify(
|
||||||
logger.debug("Will send gotify notification")
|
logger.debug("Will send gotify notification")
|
||||||
headers = {"accept": "application/json", "content-type": "application/json"}
|
headers = {"accept": "application/json", "content-type": "application/json"}
|
||||||
|
|
||||||
|
icon = get_icon_from_severity(severity)
|
||||||
priority = 9
|
priority = 9
|
||||||
icon = "❌"
|
|
||||||
if severity == Severity.OK:
|
if severity == Severity.OK:
|
||||||
priority = 1
|
priority = 1
|
||||||
icon = "✅"
|
|
||||||
elif severity == Severity.WARNING:
|
elif severity == Severity.WARNING:
|
||||||
priority = 5
|
priority = 5
|
||||||
icon = "⚠️"
|
elif severity == Severity.UNKNOWN:
|
||||||
|
priority = 5
|
||||||
|
|
||||||
subject = f"{icon} {urlparse(task.url).netloc}: status {severity}"
|
subject = f"{icon} {urlparse(task.url).netloc}: status {severity}"
|
||||||
msg = f"""\
|
msg = f"""\
|
||||||
URL: {task.url}
|
URL: <{task.url}>\\
|
||||||
Check: {task.check}
|
Check: {task.check}\\
|
||||||
Status: {severity}
|
Status: {severity}\\
|
||||||
Time: {result.submitted_at}
|
Time: {result.submitted_at}\\
|
||||||
Previous status: {old_severity}
|
Previous status: {old_severity}\\
|
||||||
|
\\
|
||||||
See result on {request.url_for('get_result_view', result_id=result.id)}
|
See result on <{request.url_for('get_result_view', result_id=result.id)}>\\
|
||||||
|
\\
|
||||||
See results of task on {request.url_for('get_task_results_view', task_id=task.id)}#{result.id}
|
See results of task on <{request.url_for('get_task_results_view', task_id=task.id)}#{result.id}>
|
||||||
"""
|
"""
|
||||||
|
extras = {
|
||||||
|
"client::display": {"contentType": "text/markdown"},
|
||||||
|
"client::notification": {
|
||||||
|
"click": {"url": request.url_for("get_result_view", result_id=result.id)}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
payload = {"title": subject, "message": msg, "priority": priority}
|
payload = {"title": subject, "message": msg, "priority": priority, "extras": extras}
|
||||||
|
|
||||||
for url in config:
|
for url in config:
|
||||||
logger.debug("Sending gotify message(s) to %s", url)
|
logger.debug("Sending gotify message(s) to %s", url)
|
||||||
|
|
81
docs/cli.md
81
docs/cli.md
|
@ -83,8 +83,11 @@ Commands:
|
||||||
generate-config Output a self-documented 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
|
||||||
|
nagios Nagios compatible severities report
|
||||||
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!)
|
||||||
|
test-apprise Send a test apprise notification
|
||||||
|
test-gotify Send a test gotify notification
|
||||||
test-mail Send a test email
|
test-mail Send a test email
|
||||||
user User management
|
user User management
|
||||||
watch-agents Watch agents (to run routinely)
|
watch-agents Watch agents (to run routinely)
|
||||||
|
@ -467,9 +470,33 @@ Options:
|
||||||
<!--[[[end]]]
|
<!--[[[end]]]
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
#### Use as a nagios probe
|
||||||
|
|
||||||
|
You can directly use Argos to get an output and an exit code usable with Nagios.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.. [[[cog
|
||||||
|
help(["server", "nagios", "--help"])
|
||||||
|
.. ]]] -->
|
||||||
|
|
||||||
|
```man
|
||||||
|
Usage: argos server nagios [OPTIONS]
|
||||||
|
|
||||||
|
Output a report of current severities suitable for Nagios with a Nagios
|
||||||
|
compatible exit code
|
||||||
|
|
||||||
|
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 email settings
|
#### Test the email settings
|
||||||
|
|
||||||
You can verify that your mail settings are ok by sending a test email
|
You can verify that your mail settings are ok by sending a test email.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
.. [[[cog
|
.. [[[cog
|
||||||
|
@ -491,3 +518,55 @@ Options:
|
||||||
|
|
||||||
<!--[[[end]]]
|
<!--[[[end]]]
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
#### Test the Gotify settings
|
||||||
|
|
||||||
|
You can verify that your Gotify settings are ok by sending a test notification.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.. [[[cog
|
||||||
|
help(["server", "test-gotify", "--help"])
|
||||||
|
.. ]]] -->
|
||||||
|
|
||||||
|
```man
|
||||||
|
Usage: argos server test-gotify [OPTIONS]
|
||||||
|
|
||||||
|
Send a test gotify notification
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
|
||||||
|
environment variable is set, its value will be used instead.
|
||||||
|
--domain TEXT Domain for the notification
|
||||||
|
--severity TEXT Severity
|
||||||
|
--help Show this message and exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
<!--[[[end]]]
|
||||||
|
-->
|
||||||
|
|
||||||
|
#### Test the Apprise settings
|
||||||
|
|
||||||
|
You can verify that your Apprise settings are ok by sending a test notification.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
.. [[[cog
|
||||||
|
help(["server", "test-apprise", "--help"])
|
||||||
|
.. ]]] -->
|
||||||
|
|
||||||
|
```man
|
||||||
|
Usage: argos server test-apprise [OPTIONS]
|
||||||
|
|
||||||
|
Send a test apprise notification
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config TEXT Path of the configuration file. If ARGOS_YAML_FILE
|
||||||
|
environment variable is set, its value will be used
|
||||||
|
instead.
|
||||||
|
--domain TEXT Domain for the notification
|
||||||
|
--severity TEXT Severity
|
||||||
|
--apprise-group TEXT Apprise group for the notification [required]
|
||||||
|
--help Show this message and exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
<!--[[[end]]]
|
||||||
|
-->
|
||||||
|
|
|
@ -22,11 +22,12 @@ classifiers = [
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alembic>=1.13.0,<1.14",
|
"alembic>=1.13.0,<1.14",
|
||||||
|
"apprise>=1.9.0,<2",
|
||||||
"bcrypt>=4.1.3,<5",
|
"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",
|
"fastapi-login>=1.10.0,<2",
|
||||||
"httpx>=0.25,<0.27.0",
|
"httpx>=0.27.2,<1",
|
||||||
"Jinja2>=3.0,<4",
|
"Jinja2>=3.0,<4",
|
||||||
"jsonpointer>=3.0,<4",
|
"jsonpointer>=3.0,<4",
|
||||||
"passlib>=1.7.4,<2",
|
"passlib>=1.7.4,<2",
|
||||||
|
|
Loading…
Reference in a new issue