init
This commit is contained in:
commit
927738ec71
16 changed files with 534 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
__pycache__
|
||||
|
||||
# nix
|
||||
result
|
||||
result-*
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
*.sw?
|
12
default.nix
Normal file
12
default.nix
Normal file
|
@ -0,0 +1,12 @@
|
|||
{ sources ? import ./npins
|
||||
, nixpkgs ? sources.nixpkgs
|
||||
, pkgs ? import nixpkgs { overlays = [ (import ./overlay.nix) ]; }
|
||||
}:
|
||||
rec {
|
||||
shell = pkgs.mkShell {
|
||||
packages = [
|
||||
python
|
||||
];
|
||||
};
|
||||
python = pkgs.python3.withPackages (ps: [ ps.click ps.click-log ps.uptime-kuma-api ]);
|
||||
}
|
67
npins/default.nix
Normal file
67
npins/default.nix
Normal file
|
@ -0,0 +1,67 @@
|
|||
# Generated by npins. Do not modify; will be overwritten regularly
|
||||
let
|
||||
data = builtins.fromJSON (builtins.readFile ./sources.json);
|
||||
version = data.version;
|
||||
|
||||
mkSource = spec:
|
||||
assert spec ? type; let
|
||||
path =
|
||||
if spec.type == "Git"
|
||||
then mkGitSource spec
|
||||
else if spec.type == "GitRelease"
|
||||
then mkGitSource spec
|
||||
else if spec.type == "PyPi"
|
||||
then mkPyPiSource spec
|
||||
else if spec.type == "Channel"
|
||||
then mkChannelSource spec
|
||||
else builtins.throw "Unknown source type ${spec.type}";
|
||||
in
|
||||
spec // {outPath = path;};
|
||||
|
||||
mkGitSource = {
|
||||
repository,
|
||||
revision,
|
||||
url ? null,
|
||||
hash,
|
||||
...
|
||||
}:
|
||||
assert repository ? type;
|
||||
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
|
||||
# In the latter case, there we will always be an url to the tarball
|
||||
if url != null
|
||||
then
|
||||
(builtins.fetchTarball {
|
||||
inherit url;
|
||||
sha256 = hash; # FIXME: check nix version & use SRI hashes
|
||||
})
|
||||
else
|
||||
assert repository.type == "Git";
|
||||
builtins.fetchGit {
|
||||
url = repository.url;
|
||||
rev = revision;
|
||||
# hash = hash;
|
||||
};
|
||||
|
||||
mkPyPiSource = {
|
||||
url,
|
||||
hash,
|
||||
...
|
||||
}:
|
||||
builtins.fetchurl {
|
||||
inherit url;
|
||||
sha256 = hash;
|
||||
};
|
||||
|
||||
mkChannelSource = {
|
||||
url,
|
||||
hash,
|
||||
...
|
||||
}:
|
||||
builtins.fetchTarball {
|
||||
inherit url;
|
||||
sha256 = hash;
|
||||
};
|
||||
in
|
||||
if version == 3
|
||||
then builtins.mapAttrs (_: mkSource) data.pins
|
||||
else throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"
|
17
npins/sources.json
Normal file
17
npins/sources.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"pins": {
|
||||
"nixpkgs": {
|
||||
"type": "Git",
|
||||
"repository": {
|
||||
"type": "GitHub",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs"
|
||||
},
|
||||
"branch": "nixos-unstable",
|
||||
"revision": "2726f127c15a4cc9810843b96cad73c7eb39e443",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/2726f127c15a4cc9810843b96cad73c7eb39e443.tar.gz",
|
||||
"hash": "0109bpmax6nbfs2mpfw2axvk47lbvksgx3d0izrjjhw7fn41i9sh"
|
||||
}
|
||||
},
|
||||
"version": 3
|
||||
}
|
7
overlay.nix
Normal file
7
overlay.nix
Normal file
|
@ -0,0 +1,7 @@
|
|||
final: prev: {
|
||||
python3 = prev.python3.override {
|
||||
packageOverrides = python-self: python-super: {
|
||||
uptime-kuma-api = python-self.callPackage ./uptime-kuma-api.nix { };
|
||||
};
|
||||
};
|
||||
}
|
26
pyproject.toml
Normal file
26
pyproject.toml
Normal file
|
@ -0,0 +1,26 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stateless-uptime-kuma"
|
||||
version = "0.1.0"
|
||||
authors = [
|
||||
{ name="sinavir", email="sinavir@sinavir.fr" },
|
||||
]
|
||||
description = "Declare uptime-kuma probes in your nix configuration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)",
|
||||
]
|
||||
dependencies = [
|
||||
"uptime-kuma-api",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.dgnum.eu/mdebray/stateless-uptime-kuma"
|
||||
|
||||
[project.scripts]
|
||||
stateless_uptime_kuma = "cli:main"
|
1
shell.nix
Normal file
1
shell.nix
Normal file
|
@ -0,0 +1 @@
|
|||
(import ./. {}).shell
|
55
stateless_uptime_kuma/cli.py
Normal file
55
stateless_uptime_kuma/cli.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import click
|
||||
import click_log
|
||||
from uptime_kuma_api import UptimeKumaApi
|
||||
|
||||
from .hydratation import hydrate_http_probes
|
||||
from .tree_gen import from_dict
|
||||
from .uptime_kuma import Manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
click_log.basic_config()
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click_log.simple_verbosity_option()
|
||||
@click.option(
|
||||
"--file",
|
||||
"-f",
|
||||
help="File to import probes data from",
|
||||
type=click.File("r"),
|
||||
default=sys.stdin,
|
||||
)
|
||||
@click.option(
|
||||
"--scrape-http-keywords",
|
||||
"-s",
|
||||
is_flag=True,
|
||||
help="Scrape keywords for http probe",
|
||||
default=False,
|
||||
)
|
||||
def apply_json(file, scrape_http_keywords):
|
||||
"""
|
||||
Apply json probes
|
||||
"""
|
||||
with UptimeKumaApi("http://localhost:3001") as api:
|
||||
api.login("admin", "123456789a")
|
||||
logging.debug("Reading json")
|
||||
data = json.load(file)
|
||||
logging.debug("Parsing json")
|
||||
tree = from_dict(api, data)
|
||||
if scrape_http_keywords:
|
||||
hydrate_http_probes(tree)
|
||||
logging.debug("Sync probes")
|
||||
Manager(api, tree).process()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
37
stateless_uptime_kuma/hydratation.py
Normal file
37
stateless_uptime_kuma/hydratation.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
import requests
|
||||
from uptime_kuma_api import MonitorType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def hydrate_http_probes(tree, excludes=[]):
|
||||
for probe in tree.get("monitors", []):
|
||||
if "type" not in probe.kwargs:
|
||||
logger.error("Fatal: probes must have a 'type' parameter")
|
||||
sys.exit(1)
|
||||
if (
|
||||
probe.kwargs["type"] == MonitorType.KEYWORD
|
||||
and probe.kwargs.get("keyword", None) is None
|
||||
):
|
||||
logger.debug(f"Hydrating {probe.name}")
|
||||
if "url" not in probe.kwargs:
|
||||
logger.error("Fatal: http probe must provide an url")
|
||||
sys.exit(1)
|
||||
if "method" not in probe.kwargs:
|
||||
logger.error("Fatal: http probe must provide a method")
|
||||
sys.exit(1)
|
||||
url = probe.kwargs["url"]
|
||||
method = probe.kwargs["method"]
|
||||
headers = probe.kwargs.get("headers", None)
|
||||
body = probe.kwargs.get("body", None)
|
||||
content = requests.request(method, url, headers=headers, data=body).text
|
||||
print(len(content))
|
||||
m = re.search("<title>(.*?)</title>", content)
|
||||
if m is None:
|
||||
logger.info(f"Didn't find keywords for probe {probe.name}, skipping")
|
||||
probe.kwargs["keyword"] = m.group(1)
|
||||
logger.debug(f"New keyword: {m.group(1)}")
|
49
stateless_uptime_kuma/tree_gen.py
Normal file
49
stateless_uptime_kuma/tree_gen.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
"""
|
||||
Classes to generate the item tree from json spec
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from .uptime_kuma import Monitor, Notification, Tag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def die_tag_format_error():
|
||||
logger.error(
|
||||
"Fatal: You must provide tags in monitors in the format [[name, value]]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def from_dict(api, tree):
|
||||
notif = tree.get("notifications", [])
|
||||
indexed_notifications = {n["name"]: Notification(api, **n) for n in notif}
|
||||
tags = tree.get("tags", [])
|
||||
indexed_tags = {t["name"]: Tag(api, **t) for t in tags}
|
||||
monitors = tree.get("monitors", [])
|
||||
indexed_monitors = {}
|
||||
for m in monitors:
|
||||
associated_tags = []
|
||||
for tag in m.get("tags", []):
|
||||
if not isinstance(tag, list):
|
||||
die_tag_format_error()
|
||||
try:
|
||||
associated_tags.append((indexed_tags[tag[0]], tag[1]))
|
||||
except IndexError:
|
||||
die_tag_format_error()
|
||||
m["tags"] = associated_tags
|
||||
associated_notifications = [
|
||||
indexed_notifications[notif] for notif in m.get("notifications", [])
|
||||
]
|
||||
m["notifications"] = associated_notifications
|
||||
if "name" not in m:
|
||||
logger.error("Fatal: All monitors must have a name")
|
||||
sys.exit(1)
|
||||
indexed_monitors[m["name"]] = Monitor(api, **m)
|
||||
return {
|
||||
"monitors": indexed_monitors.values(),
|
||||
"tags": indexed_tags.values(),
|
||||
"notifications": indexed_notifications.values(),
|
||||
}
|
167
stateless_uptime_kuma/uptime_kuma.py
Normal file
167
stateless_uptime_kuma/uptime_kuma.py
Normal file
|
@ -0,0 +1,167 @@
|
|||
"""
|
||||
Classes to make the needed operations to reach the specified state.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self, api, target_tree={}, prune_unused=False):
|
||||
self.api = api
|
||||
self.prune_unused = prune_unused
|
||||
self.target_tree = target_tree
|
||||
|
||||
def process(self):
|
||||
self.sync_tags()
|
||||
self.sync_notifications()
|
||||
self.sync_monitors()
|
||||
self.save()
|
||||
|
||||
def save(self):
|
||||
for v in self.target_tree.values():
|
||||
for i in v:
|
||||
i.save() # this method should be safe to be called in whatever order
|
||||
|
||||
def sync_monitors(self):
|
||||
old = self.api.get_monitors()
|
||||
new = self.target_tree.get("monitors", [])
|
||||
self.sync(new, old)
|
||||
|
||||
def sync_notifications(self):
|
||||
old = self.api.get_notifications()
|
||||
new = self.target_tree.get("notifications", [])
|
||||
self.sync(new, old)
|
||||
|
||||
def sync_tags(self):
|
||||
old = self.api.get_tags()
|
||||
new = self.target_tree.get("tags", [])
|
||||
self.sync(new, old)
|
||||
|
||||
def sync(self, new, old):
|
||||
indexed_old = {elem["name"]: elem for elem in old}
|
||||
for k in new:
|
||||
if k.name in indexed_old:
|
||||
k.id = indexed_old[k.name]["id"]
|
||||
logger.debug(f"Synced item named {k}")
|
||||
if k.old_name is not None:
|
||||
logger.warn(f"Found unused oldName for {k}")
|
||||
elif k.old_name in indexed_old:
|
||||
k.id = indexed_old[k.old_name]["id"]
|
||||
logger.info(f"Found renamed item {k.old_name} -> {k}")
|
||||
else:
|
||||
k.id = None # Useless
|
||||
logger.debug(f"Creating key {k}")
|
||||
|
||||
|
||||
class Item:
|
||||
def __init__(self, api, name, id, old_name=None):
|
||||
self.api = api
|
||||
self.name = name
|
||||
self.id = id
|
||||
self.old_name = old_name
|
||||
self.saved = False
|
||||
|
||||
def save(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name != "saved":
|
||||
self.saved = False
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Monitor(Item):
|
||||
def __init__(
|
||||
self, api, name, id=None, old_name=None, tags=[], notifications=[], **kwargs
|
||||
):
|
||||
super().__init__(api, name, id, old_name)
|
||||
self.kwargs = kwargs
|
||||
self.tags = tags
|
||||
self.notifications = notifications
|
||||
self.saved = False
|
||||
|
||||
def save(self):
|
||||
if self.saved:
|
||||
return
|
||||
for t, _ in self.tags:
|
||||
t.save()
|
||||
for n in self.notifications:
|
||||
n.save()
|
||||
if self.id is None:
|
||||
rslt = self.api.add_monitor(
|
||||
name=self.name,
|
||||
notificationIDList=[i.id for i in self.notifications],
|
||||
**self.kwargs,
|
||||
)
|
||||
self.id = rslt["monitorID"]
|
||||
for t, value in self.tags:
|
||||
self.api.add_monitor_tag(tag_id=t.id, monitor_id=self.id, value=value)
|
||||
else:
|
||||
rslt = self.api.edit_monitor(
|
||||
self.id,
|
||||
name=self.name,
|
||||
notificationIDList=[i.id for i in self.notifications],
|
||||
**self.kwargs,
|
||||
)
|
||||
current_tags = set(
|
||||
(i["tag_id"], i["value"]) for i in self.api.get_monitor(self.id)["tags"]
|
||||
)
|
||||
for t, v in self.tags:
|
||||
if (t, v) not in current_tags:
|
||||
self.api.add_monitor_tag(tag_id=t.id, monitor_id=self.id, value=v)
|
||||
self.saved = True
|
||||
|
||||
def __repr__(self):
|
||||
return f"Monitor({str(self)})"
|
||||
|
||||
|
||||
class Tag(Item):
|
||||
def __init__(self, api, name, id=None, old_name=None, color="#000000"):
|
||||
super().__init__(api, name, id, old_name)
|
||||
self.tag = name
|
||||
self.color = color
|
||||
|
||||
def save(self):
|
||||
if self.saved:
|
||||
return
|
||||
if self.id is None:
|
||||
rslt = self.api.add_tag(
|
||||
name=self.tag,
|
||||
color=self.color,
|
||||
)
|
||||
self.id = rslt["id"]
|
||||
else:
|
||||
self.api.edit_tag(
|
||||
id_=self.id,
|
||||
name=self.tag,
|
||||
color=self.color,
|
||||
)
|
||||
self.saved = True
|
||||
|
||||
|
||||
class Notification(Item):
|
||||
def __init__(self, api, name, id=None, old_name=None, **kwargs):
|
||||
super().__init__(api, name, id, old_name)
|
||||
self.kwargs = kwargs
|
||||
|
||||
def save(self):
|
||||
if self.saved:
|
||||
return
|
||||
if self.id is None:
|
||||
rslt = self.api.add_notification(
|
||||
name=self.name,
|
||||
**self.kwargs,
|
||||
)
|
||||
self.id = rslt["id"]
|
||||
else:
|
||||
self.api.edit_notification(
|
||||
id_=self.id,
|
||||
name=self.name,
|
||||
**self.kwargs,
|
||||
)
|
||||
self.saved = True
|
7
tests/00_simple_ping_probe.json
Normal file
7
tests/00_simple_ping_probe.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"monitors": [{
|
||||
"name": "test_monitor",
|
||||
"type": "ping",
|
||||
"hostname": "localhost"
|
||||
}]
|
||||
}
|
6
tests/01_rename_probe.json
Normal file
6
tests/01_rename_probe.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"monitors": [{
|
||||
"old_name": "test_monitor",
|
||||
"name": "monitor_test"
|
||||
}]
|
||||
}
|
21
tests/02_tags.json
Normal file
21
tests/02_tags.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"notifications":[{
|
||||
"name": "dgn",
|
||||
"type": "ntfy",
|
||||
"ntfyAuthenticationMethod": "none",
|
||||
"ntfytopic": "dgnum-test",
|
||||
"ntfyserverurl": "https://htfy.sh",
|
||||
"ntfyPriority": 5
|
||||
}],
|
||||
"tags":[{
|
||||
"name":"test"
|
||||
}],
|
||||
"monitors": [{
|
||||
"tags": [["test", "value"]],
|
||||
"notifications": [ "dgn" ],
|
||||
"old_name": "test_monitor",
|
||||
"name": "test_monitor2",
|
||||
"type": "ping",
|
||||
"hostname": "localhost"
|
||||
}]
|
||||
}
|
8
tests/03_keywords.json
Normal file
8
tests/03_keywords.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"monitors": [{
|
||||
"name": "google",
|
||||
"type": "keyword",
|
||||
"url": "https://google.com",
|
||||
"method": "get"
|
||||
}]
|
||||
}
|
43
uptime-kuma-api.nix
Normal file
43
uptime-kuma-api.nix
Normal file
|
@ -0,0 +1,43 @@
|
|||
{ lib
|
||||
, buildPythonPackage
|
||||
, fetchFromGitHub
|
||||
, setuptools
|
||||
, wheel
|
||||
, packaging
|
||||
, python-socketio
|
||||
, requests
|
||||
}:
|
||||
|
||||
buildPythonPackage rec {
|
||||
pname = "uptime-kuma-api";
|
||||
version = "1.2.1";
|
||||
pyproject = true;
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "lucasheld";
|
||||
repo = "uptime-kuma-api";
|
||||
rev = version;
|
||||
hash = "sha256-Mgp4bSQPiEeulK9dAl+Di4Nj1HG3oVFGKr1bIdRZI44=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
setuptools
|
||||
wheel
|
||||
];
|
||||
|
||||
propagatedBuildInputs = [
|
||||
packaging
|
||||
python-socketio
|
||||
requests
|
||||
];
|
||||
|
||||
pythonImportsCheck = [ "uptime_kuma_api" ];
|
||||
|
||||
meta = with lib; {
|
||||
description = "A Python wrapper for the Uptime Kuma Socket.IO API";
|
||||
homepage = "https://github.com/lucasheld/uptime-kuma-api";
|
||||
changelog = "https://github.com/lucasheld/uptime-kuma-api/blob/${src.rev}/CHANGELOG.md";
|
||||
license = licenses.mit;
|
||||
maintainers = with maintainers; [ ];
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue