[ini]
This commit is contained in:
commit
82b47ffa14
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.geany
|
||||||
|
build/
|
||||||
|
|
||||||
299
hmdl.schema.json
Normal file
299
hmdl.schema.json
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
{
|
||||||
|
"$defs": {
|
||||||
|
"active": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"minutely",
|
||||||
|
"hourly",
|
||||||
|
"daily"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"type": "array",
|
||||||
|
"item": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "console"
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"parameters"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "email"
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"access": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"host": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"host",
|
||||||
|
"port",
|
||||||
|
"username",
|
||||||
|
"password"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"receivers": {
|
||||||
|
"type": "array",
|
||||||
|
"item": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"description": "list of strings, which will be placed in the e-mail subject",
|
||||||
|
"type": "array",
|
||||||
|
"item": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"access",
|
||||||
|
"sender",
|
||||||
|
"receivers"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"parameters"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": [
|
||||||
|
{
|
||||||
|
"kind": "console",
|
||||||
|
"parameters": {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"defaults": {
|
||||||
|
"description": "default values for checks",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"active": {
|
||||||
|
"$ref": "#/$defs/active"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"$ref": "#/$defs/schedule"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"$ref": "#/$defs/notifications"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"description": "should represent a specific check",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"$ref": "#/$defs/active"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"$ref": "#/$defs/schedule"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"$ref": "#/$defs/notifications"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "script"
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"arguments": {
|
||||||
|
"type": "array",
|
||||||
|
"item": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"parameters"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "http_request"
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"request": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"target": {
|
||||||
|
"description": "URL",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"GET",
|
||||||
|
"POST"
|
||||||
|
],
|
||||||
|
"default": "GET"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"target"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"status_code": {
|
||||||
|
"description": "checks whether the response status code is this",
|
||||||
|
"type": ["null", "integer"],
|
||||||
|
"default": 200
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"description": "conjunctively checks header key-value pairs",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"description": "header value",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
],
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"body_part": {
|
||||||
|
"description": "checks whether the response body contains this string",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"as_warning": {
|
||||||
|
"description": "whether a violation of this check shall be exposed as warning instead of critical; default: false",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"request"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"parameters"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"defaults",
|
||||||
|
"groups"
|
||||||
|
]
|
||||||
|
}
|
||||||
56
readme.md
Normal file
56
readme.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Heimdall
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
|
||||||
|
- führt Prüfungen durch um den Zustand von Systemen zu überwachen und meldet Unstimmigkeiten
|
||||||
|
|
||||||
|
|
||||||
|
## Technologien
|
||||||
|
|
||||||
|
- python
|
||||||
|
|
||||||
|
|
||||||
|
## Erstellung
|
||||||
|
|
||||||
|
- `tools/build`
|
||||||
|
|
||||||
|
|
||||||
|
## Austührung
|
||||||
|
|
||||||
|
- siehe `build/heimdall -h`
|
||||||
|
- sollte als minütlich laufender Cronjob aufgerufen werden
|
||||||
|
|
||||||
|
|
||||||
|
### Eigene Skripte
|
||||||
|
|
||||||
|
Mittels den Prüfungs-Art `script`, kann man selbst definierte Prüf-Funktionen schreiben. Diese Skripte sollen durch exit-Codes das Ergebnis der Prüfung kommunizieren:
|
||||||
|
|
||||||
|
- `0`: alles prima
|
||||||
|
- `1`: Warnung
|
||||||
|
- `2`: kritisch
|
||||||
|
- alles andere: Fehler bei Ausführung (unbekannter Status)
|
||||||
|
|
||||||
|
Infos (z.B. was genau schief gelaufen ist) sollen zu `stdout` geschrieben werden.
|
||||||
|
|
||||||
|
|
||||||
|
## Testen
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
|
||||||
|
## Ausrollen
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
|
||||||
|
## Überwachung
|
||||||
|
|
||||||
|
(nicht relevant)
|
||||||
|
|
||||||
|
|
||||||
|
## Sicherung
|
||||||
|
|
||||||
|
(nicht relevant)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
source/__pycache__/interface_check_kind.cpython-310.pyc
Normal file
BIN
source/__pycache__/interface_check_kind.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
source/__pycache__/lib.cpython-310.pyc
Normal file
BIN
source/__pycache__/lib.cpython-310.pyc
Normal file
Binary file not shown.
270
source/heimdall.py
Executable file
270
source/heimdall.py
Executable file
|
|
@ -0,0 +1,270 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
import os as _os
|
||||||
|
import json as _json
|
||||||
|
import argparse as _argparse
|
||||||
|
|
||||||
|
from lib import *
|
||||||
|
from implementation_check_kind_script import *
|
||||||
|
from implementation_check_kind_http_request import *
|
||||||
|
from implementation_notification_channel_console import *
|
||||||
|
from implementation_notification_channel_email import *
|
||||||
|
|
||||||
|
|
||||||
|
def state_encode(state):
|
||||||
|
return {
|
||||||
|
"timestamp": state["timestamp"],
|
||||||
|
"condition": condition_encode(state["condition"]),
|
||||||
|
"count": state["count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def state_decode(state_encoded):
|
||||||
|
return {
|
||||||
|
"timestamp": state_encoded["timestamp"],
|
||||||
|
"condition": condition_decode(state_encoded["condition"]),
|
||||||
|
"count": state_encoded["count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def conf_normalize_check(check_kind_implementations, defaults, name, node):
|
||||||
|
if ("kind" not in node):
|
||||||
|
raise ValueError("missing mandatory 'member' field 'kind'")
|
||||||
|
else:
|
||||||
|
if (node["kind"] not in check_kind_implementations):
|
||||||
|
raise ValueError("unhandled kind: %s" % node["kind"])
|
||||||
|
else:
|
||||||
|
node_ = dict_merge(
|
||||||
|
{
|
||||||
|
"title": name,
|
||||||
|
"active": True,
|
||||||
|
"schedule": defaults["schedule"],
|
||||||
|
"notifications": defaults["notifications"],
|
||||||
|
"parameters": {},
|
||||||
|
},
|
||||||
|
node
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"title": node_["title"],
|
||||||
|
"active": node_["active"],
|
||||||
|
"schedule": node_["schedule"],
|
||||||
|
"notifications": node_["notifications"],
|
||||||
|
"kind": node_["kind"],
|
||||||
|
"parameters": check_kind_implementations[node_["kind"]].normalize_conf_node(node_["parameters"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def conf_normalize_defaults(node):
|
||||||
|
return dict_merge(
|
||||||
|
{
|
||||||
|
"active": True,
|
||||||
|
"schedule": {"kind": "hourly"},
|
||||||
|
"notifications": [],
|
||||||
|
},
|
||||||
|
node
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def conf_normalize_root(check_kind_implementations, node):
|
||||||
|
return dict(
|
||||||
|
map(
|
||||||
|
lambda check_pair: (
|
||||||
|
check_pair[0],
|
||||||
|
conf_normalize_check(
|
||||||
|
check_kind_implementations,
|
||||||
|
conf_normalize_defaults(node["defaults"]),
|
||||||
|
check_pair[0],
|
||||||
|
check_pair[1]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
node["checks"].items()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
## args
|
||||||
|
argumentparser = _argparse.ArgumentParser(
|
||||||
|
description = "monitoring processor",
|
||||||
|
formatter_class = _argparse.ArgumentDefaultsHelpFormatter
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-c",
|
||||||
|
"--conf-path",
|
||||||
|
type = str,
|
||||||
|
default = "conf.json",
|
||||||
|
dest = "conf_path",
|
||||||
|
metavar = "<conf-path>",
|
||||||
|
help = "path to the configuration file"
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--state-path",
|
||||||
|
type = str,
|
||||||
|
default = "/tmp/monitoring-state.json",
|
||||||
|
dest = "state_path",
|
||||||
|
metavar = "<state-path>",
|
||||||
|
help = "path to the state file, which contains information about the recent checks"
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--threshold",
|
||||||
|
type = int,
|
||||||
|
default = 3,
|
||||||
|
dest = "threshold",
|
||||||
|
metavar = "<threshold>",
|
||||||
|
help = "how often a condition has to occur in order to be reported"
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-k",
|
||||||
|
"--keep-notifying",
|
||||||
|
action = "store_true",
|
||||||
|
default = False,
|
||||||
|
dest = "keep_notifying",
|
||||||
|
help = "whether notifications shall be kept sending after the threshold has been surpassed"
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-x",
|
||||||
|
"--expose-full-conf",
|
||||||
|
action = "store_true",
|
||||||
|
default = False,
|
||||||
|
dest = "expose_full_conf",
|
||||||
|
help = "only print the extended configuration to stdout and exit (useful for debug purposes)"
|
||||||
|
)
|
||||||
|
args = argumentparser.parse_args()
|
||||||
|
|
||||||
|
## exec
|
||||||
|
|
||||||
|
### load check kind implementations
|
||||||
|
check_kind_implementations = {
|
||||||
|
"script": implementation_check_kind_script(),
|
||||||
|
"http_request": implementation_check_kind_http_request(),
|
||||||
|
}
|
||||||
|
|
||||||
|
### load notification channel implementations
|
||||||
|
notification_channel_implementations = {
|
||||||
|
"console": implementation_notification_channel_console(),
|
||||||
|
"email": implementation_notification_channel_email(),
|
||||||
|
}
|
||||||
|
|
||||||
|
### get configuration data
|
||||||
|
checks = conf_normalize_root(check_kind_implementations, _json.loads(file_read(args.conf_path)))
|
||||||
|
if (args.expose_full_conf):
|
||||||
|
_sys.stdout.write(_json.dumps(checks, indent = "\t") + "\n")
|
||||||
|
_sys.exit(1)
|
||||||
|
else:
|
||||||
|
### get state data
|
||||||
|
if (not _os.path.exists(args.state_path)):
|
||||||
|
state_data = {}
|
||||||
|
file_write(args.state_path, _json.dumps(state_data, indent = "\t"))
|
||||||
|
else:
|
||||||
|
state_data = _json.loads(file_read(args.state_path))
|
||||||
|
|
||||||
|
### iterate through checks
|
||||||
|
for (check_name, check_data, ) in checks.items():
|
||||||
|
if (not check_data["active"]):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
### get old state and examine whether the check shall be executed
|
||||||
|
old_item_state = (
|
||||||
|
None
|
||||||
|
if (check_name not in state_data) else
|
||||||
|
state_decode(state_data[check_name])
|
||||||
|
)
|
||||||
|
timestamp = get_current_timestamp()
|
||||||
|
due = (
|
||||||
|
(old_item_state is None)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(old_item_state["count"] is not None)
|
||||||
|
and
|
||||||
|
((timestamp - old_item_state["timestamp"]) >= (1 * 5))
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(
|
||||||
|
(check_data["schedule"]["kind"] == "minutely")
|
||||||
|
and
|
||||||
|
((timestamp - old_item_state["timestamp"]) >= (60))
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(check_data["schedule"]["kind"] == "hourly")
|
||||||
|
and
|
||||||
|
((timestamp - old_item_state["timestamp"]) >= (60 * 60))
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(check_data["schedule"]["kind"] == "daily")
|
||||||
|
and
|
||||||
|
((timestamp - old_item_state["timestamp"]) >= (60 * 60 * 24))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (not due):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
_sys.stderr.write(
|
||||||
|
string_coin(
|
||||||
|
"-- {{check_name}}\n",
|
||||||
|
{
|
||||||
|
"check_name": check_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
### execute check and set new state
|
||||||
|
result = check_kind_implementations[check_data["kind"]].run(check_data)
|
||||||
|
new_item_state = {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"condition": result["condition"],
|
||||||
|
"count": (
|
||||||
|
1
|
||||||
|
if (
|
||||||
|
(old_item_state is None)
|
||||||
|
or
|
||||||
|
(old_item_state["condition"] != result["condition"])
|
||||||
|
) else
|
||||||
|
(
|
||||||
|
(old_item_state["count"] + 1)
|
||||||
|
if (
|
||||||
|
(old_item_state["count"] is not None)
|
||||||
|
and
|
||||||
|
((old_item_state["count"] + 1) <= args.threshold)
|
||||||
|
) else
|
||||||
|
None
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
state_data[check_name] = state_encode(new_item_state)
|
||||||
|
file_write(args.state_path, _json.dumps(state_data, indent = "\t"))
|
||||||
|
|
||||||
|
### send notifications
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
(new_item_state["count"] is not None)
|
||||||
|
and
|
||||||
|
(new_item_state["count"] == args.threshold)
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(new_item_state["count"] is None)
|
||||||
|
and
|
||||||
|
args.keep_notifying
|
||||||
|
)
|
||||||
|
):
|
||||||
|
for notification in check_data["notifications"]:
|
||||||
|
if (notification["kind"] in notification_channel_implementations):
|
||||||
|
notification_channel_implementations[notification["kind"]].notify(
|
||||||
|
notification["parameters"],
|
||||||
|
check_name,
|
||||||
|
check_data,
|
||||||
|
new_item_state,
|
||||||
|
result["output"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("invalid notification kind: %s" % notification["kind"])
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
120
source/implementation_check_kind_http_request.py
Normal file
120
source/implementation_check_kind_http_request.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
class implementation_check_kind_http_request(interface_check_kind):
|
||||||
|
|
||||||
|
'''
|
||||||
|
[implementation]
|
||||||
|
'''
|
||||||
|
def normalize_conf_node(self, node):
|
||||||
|
return dict_merge(
|
||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status_code": 200
|
||||||
|
},
|
||||||
|
"as_warning": False,
|
||||||
|
},
|
||||||
|
node,
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
[implementation]
|
||||||
|
'''
|
||||||
|
def run(self, check_data):
|
||||||
|
if (check_data["parameters"]["request"]["method"] == "GET"):
|
||||||
|
method_handled = True
|
||||||
|
try:
|
||||||
|
response = _requests.get(
|
||||||
|
check_data["parameters"]["request"]["target"]
|
||||||
|
)
|
||||||
|
error = None
|
||||||
|
except Exception as error_:
|
||||||
|
error = error_
|
||||||
|
response = None
|
||||||
|
elif (check_data["parameters"]["request"]["method"] == "POST"):
|
||||||
|
method_handled = True
|
||||||
|
try:
|
||||||
|
response = _requests.post(
|
||||||
|
check_data["parameters"]["request"]["target"]
|
||||||
|
)
|
||||||
|
error = None
|
||||||
|
except Exception as error_:
|
||||||
|
error = error_
|
||||||
|
response = None
|
||||||
|
else:
|
||||||
|
method_handled = False
|
||||||
|
response = None
|
||||||
|
if (not method_handled):
|
||||||
|
return {
|
||||||
|
"condition": enum_condition.unknown,
|
||||||
|
"output": ("invalid HTTP request method: %s" % check_data["parameters"]["request"]["method"])
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
if (response is None):
|
||||||
|
return {
|
||||||
|
"condition": (
|
||||||
|
enum_condition.warning
|
||||||
|
if check_data["parameters"]["as_warning"] else
|
||||||
|
enum_condition.critical
|
||||||
|
),
|
||||||
|
"output": "HTTP request failed",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
lines = []
|
||||||
|
for (key, value, ) in check_data["parameters"]["response"].items():
|
||||||
|
if (key == "status_code"):
|
||||||
|
if ((value is None) or (response.status_code == value)):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
string_coin(
|
||||||
|
"actual status code {{status_code_actual}} does not match expected value {{status_code_expected}}",
|
||||||
|
{
|
||||||
|
"status_code_actual": ("%u" % response.status_code),
|
||||||
|
"status_code_expected": ("%u" % value),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif (key == "headers"):
|
||||||
|
for (header_key, header_value, ) in value.items():
|
||||||
|
if (response.headers[header_key] == header_value):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
string_coin(
|
||||||
|
"actual header value for key {{key}} is {{value_actual}} and does not match the expected value {{value_expected}}",
|
||||||
|
{
|
||||||
|
"key": header_key,
|
||||||
|
"value_actual": response.headers[header_key],
|
||||||
|
"value_expected": header_value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif (key == "body_part"):
|
||||||
|
if (response.text.find(value) >= 0):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
string_coin(
|
||||||
|
"body does not contain the expected part '{{part}}'",
|
||||||
|
{
|
||||||
|
"part": value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("unhandled ")
|
||||||
|
return {
|
||||||
|
"condition": (
|
||||||
|
enum_condition.ok
|
||||||
|
if (len(lines) <= 0) else
|
||||||
|
(
|
||||||
|
enum_condition.warning
|
||||||
|
if check_data["parameters"]["as_warning"] else
|
||||||
|
enum_condition.critical
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"output": "\n".join(lines),
|
||||||
|
}
|
||||||
37
source/implementation_check_kind_script.py
Normal file
37
source/implementation_check_kind_script.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
class implementation_check_kind_script(interface_check_kind):
|
||||||
|
|
||||||
|
'''
|
||||||
|
[implementation]
|
||||||
|
'''
|
||||||
|
def normalize_conf_node(self, node):
|
||||||
|
return dict_merge(
|
||||||
|
{
|
||||||
|
},
|
||||||
|
node
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
[implementation]
|
||||||
|
'''
|
||||||
|
def run(self, check_data):
|
||||||
|
result = _subprocess.run(
|
||||||
|
[check_data["parameters"]["path"]] + check_data["parameters"]["arguments"],
|
||||||
|
capture_output = True
|
||||||
|
)
|
||||||
|
if (result.returncode == 0):
|
||||||
|
condition = enum_condition.ok
|
||||||
|
elif (result.returncode == 1):
|
||||||
|
condition = enum_condition.unknown
|
||||||
|
elif (result.returncode == 2):
|
||||||
|
condition = enum_condition.warning
|
||||||
|
elif (result.returncode == 3):
|
||||||
|
condition = enum_condition.critical
|
||||||
|
else:
|
||||||
|
raise ValueError("invalid exit code: %i" % result.returncode)
|
||||||
|
output = result.stdout.decode()
|
||||||
|
return {
|
||||||
|
"condition": condition,
|
||||||
|
"output": output,
|
||||||
|
}
|
||||||
|
|
||||||
16
source/implementation_notification_channel_console.py
Normal file
16
source/implementation_notification_channel_console.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
class implementation_notification_channel_console(interface_notification_channel):
|
||||||
|
|
||||||
|
'''
|
||||||
|
[implementation]
|
||||||
|
'''
|
||||||
|
def notify(self, parameters, name, data, state, output):
|
||||||
|
_sys.stdout.write(
|
||||||
|
string_coin(
|
||||||
|
"[{{title}}] <{{condition}}> {{output}}\n",
|
||||||
|
{
|
||||||
|
"title": data["title"],
|
||||||
|
"condition": condition_encode(state["condition"]),
|
||||||
|
"output": ("(no infos)" if (output is None) else output),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
44
source/implementation_notification_channel_email.py
Normal file
44
source/implementation_notification_channel_email.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
class implementation_notification_channel_email(interface_notification_channel):
|
||||||
|
|
||||||
|
'''
|
||||||
|
[implementation]
|
||||||
|
'''
|
||||||
|
def notify(self, parameters, name, data, state, output):
|
||||||
|
smtp_connection = _smtplib.SMTP(
|
||||||
|
parameters["access"]["host"]
|
||||||
|
)
|
||||||
|
smtp_connection.login(
|
||||||
|
parameters["access"]["username"],
|
||||||
|
parameters["access"]["password"]
|
||||||
|
)
|
||||||
|
message = MIMEText(
|
||||||
|
string_coin(
|
||||||
|
("(no infos)" if (output is None) else output),
|
||||||
|
{
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message["Subject"] = string_coin(
|
||||||
|
"{{tags}} {{title}}",
|
||||||
|
{
|
||||||
|
"tags": " ".join(
|
||||||
|
map(
|
||||||
|
lambda tag: ("[%s]" % tag.upper()),
|
||||||
|
(
|
||||||
|
parameters["tags"]
|
||||||
|
+
|
||||||
|
[condition_encode(state["condition"])]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"title": data["title"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
message["From"] = parameters["sender"]
|
||||||
|
message["To"] = ",".join(parameters["receivers"])
|
||||||
|
smtp_connection.sendmail(
|
||||||
|
parameters["sender"],
|
||||||
|
parameters["receivers"],
|
||||||
|
message.as_string()
|
||||||
|
)
|
||||||
|
smtp_connection.quit()
|
||||||
9
source/interface_check_kind.py
Normal file
9
source/interface_check_kind.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
class interface_check_kind(object):
|
||||||
|
|
||||||
|
def normalize_conf_node(self, node):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
def run(self, check_data):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
5
source/interface_notification_channel.py
Normal file
5
source/interface_notification_channel.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
class interface_notification_channel(object):
|
||||||
|
|
||||||
|
def notify(self, parameters, name, data, state, output):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
77
source/lib.py
Normal file
77
source/lib.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import enum as _enum
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
|
||||||
|
def file_read(path):
|
||||||
|
handle = open(path, "r")
|
||||||
|
content = handle.read()
|
||||||
|
handle.close()
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def file_write(path, content):
|
||||||
|
handle = open(path, "w")
|
||||||
|
handle.write(content)
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def string_coin(template, arguments):
|
||||||
|
result = template
|
||||||
|
for (key, value, ) in arguments.items():
|
||||||
|
result = result.replace("{{%s}}" % key, value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_timestamp():
|
||||||
|
return int(round(_time.time(), 0))
|
||||||
|
|
||||||
|
|
||||||
|
def dict_merge(core_dict, mantle_dict, recursive = False):
|
||||||
|
result_dict = {}
|
||||||
|
for current_dict in [core_dict, mantle_dict]:
|
||||||
|
for (key, value, ) in current_dict.items():
|
||||||
|
if (not (key in result_dict)):
|
||||||
|
result_dict[key] = value
|
||||||
|
else:
|
||||||
|
if (recursive and (type(result_dict[key]) == dict) and (type(value) == dict)):
|
||||||
|
result_dict[key] = dict_merge(result_dict[key], value)
|
||||||
|
elif (recursive and (type(result_dict[key]) == list) and (type(value) == list)):
|
||||||
|
result_dict[key] = (result_dict[key] + value)
|
||||||
|
else:
|
||||||
|
result_dict[key] = value
|
||||||
|
return result_dict
|
||||||
|
|
||||||
|
|
||||||
|
class enum_condition(_enum.Enum):
|
||||||
|
unknown = 0
|
||||||
|
ok = 1
|
||||||
|
warning = 2
|
||||||
|
critical = 3
|
||||||
|
|
||||||
|
|
||||||
|
def condition_encode(condition):
|
||||||
|
if (condition == enum_condition.ok):
|
||||||
|
return "ok"
|
||||||
|
elif (condition == enum_condition.unknown):
|
||||||
|
return "unknown"
|
||||||
|
elif (condition == enum_condition.warning):
|
||||||
|
return "warning"
|
||||||
|
elif (condition == enum_condition.critical):
|
||||||
|
return "critical"
|
||||||
|
else:
|
||||||
|
raise ValueError("unhandled condition: %s" % str(condition))
|
||||||
|
|
||||||
|
|
||||||
|
def condition_decode(condition_encoded):
|
||||||
|
if (condition_encoded == "ok"):
|
||||||
|
return enum_condition.ok
|
||||||
|
elif (condition_encoded == "unknown"):
|
||||||
|
return enum_condition.unknown
|
||||||
|
elif (condition_encoded == "warning"):
|
||||||
|
return enum_condition.warning
|
||||||
|
elif (condition_encoded == "critical"):
|
||||||
|
return enum_condition.critical
|
||||||
|
else:
|
||||||
|
raise ValueError("unhandled encoded condition: %s" % condition_encoded)
|
||||||
|
|
||||||
|
|
||||||
256
source/main.py
Normal file
256
source/main.py
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
def state_encode(state):
|
||||||
|
return {
|
||||||
|
"timestamp": state["timestamp"],
|
||||||
|
"condition": condition_encode(state["condition"]),
|
||||||
|
"count": state["count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def state_decode(state_encoded):
|
||||||
|
return {
|
||||||
|
"timestamp": state_encoded["timestamp"],
|
||||||
|
"condition": condition_decode(state_encoded["condition"]),
|
||||||
|
"count": state_encoded["count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def conf_normalize_check(check_kind_implementations, defaults, name, node):
|
||||||
|
if ("kind" not in node):
|
||||||
|
raise ValueError("missing mandatory 'member' field 'kind'")
|
||||||
|
else:
|
||||||
|
if (node["kind"] not in check_kind_implementations):
|
||||||
|
raise ValueError("unhandled kind: %s" % node["kind"])
|
||||||
|
else:
|
||||||
|
node_ = dict_merge(
|
||||||
|
{
|
||||||
|
"title": name,
|
||||||
|
"active": True,
|
||||||
|
"schedule": defaults["schedule"],
|
||||||
|
"notifications": defaults["notifications"],
|
||||||
|
"parameters": {},
|
||||||
|
},
|
||||||
|
node
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"title": node_["title"],
|
||||||
|
"active": node_["active"],
|
||||||
|
"schedule": node_["schedule"],
|
||||||
|
"notifications": node_["notifications"],
|
||||||
|
"kind": node_["kind"],
|
||||||
|
"parameters": check_kind_implementations[node_["kind"]].normalize_conf_node(node_["parameters"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def conf_normalize_defaults(node):
|
||||||
|
return dict_merge(
|
||||||
|
{
|
||||||
|
"active": True,
|
||||||
|
"schedule": {"kind": "hourly"},
|
||||||
|
"notifications": [],
|
||||||
|
},
|
||||||
|
node
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def conf_normalize_root(check_kind_implementations, node):
|
||||||
|
return dict(
|
||||||
|
map(
|
||||||
|
lambda check_pair: (
|
||||||
|
check_pair[0],
|
||||||
|
conf_normalize_check(
|
||||||
|
check_kind_implementations,
|
||||||
|
conf_normalize_defaults(node["defaults"]),
|
||||||
|
check_pair[0],
|
||||||
|
check_pair[1]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
node["checks"].items()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
## args
|
||||||
|
argumentparser = _argparse.ArgumentParser(
|
||||||
|
description = "monitoring processor",
|
||||||
|
formatter_class = _argparse.ArgumentDefaultsHelpFormatter
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-c",
|
||||||
|
"--conf-path",
|
||||||
|
type = str,
|
||||||
|
default = "conf.json",
|
||||||
|
dest = "conf_path",
|
||||||
|
metavar = "<conf-path>",
|
||||||
|
help = "path to the configuration file"
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--state-path",
|
||||||
|
type = str,
|
||||||
|
default = "/tmp/monitoring-state.json",
|
||||||
|
dest = "state_path",
|
||||||
|
metavar = "<state-path>",
|
||||||
|
help = "path to the state file, which contains information about the recent checks"
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--threshold",
|
||||||
|
type = int,
|
||||||
|
default = 3,
|
||||||
|
dest = "threshold",
|
||||||
|
metavar = "<threshold>",
|
||||||
|
help = "how often a condition has to occur in order to be reported"
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-k",
|
||||||
|
"--keep-notifying",
|
||||||
|
action = "store_true",
|
||||||
|
default = False,
|
||||||
|
dest = "keep_notifying",
|
||||||
|
help = "whether notifications shall be kept sending after the threshold has been surpassed"
|
||||||
|
)
|
||||||
|
argumentparser.add_argument(
|
||||||
|
"-x",
|
||||||
|
"--expose-full-conf",
|
||||||
|
action = "store_true",
|
||||||
|
default = False,
|
||||||
|
dest = "expose_full_conf",
|
||||||
|
help = "only print the extended configuration to stdout and exit (useful for debug purposes)"
|
||||||
|
)
|
||||||
|
args = argumentparser.parse_args()
|
||||||
|
|
||||||
|
## exec
|
||||||
|
|
||||||
|
### load check kind implementations
|
||||||
|
check_kind_implementations = {
|
||||||
|
"script": implementation_check_kind_script(),
|
||||||
|
"http_request": implementation_check_kind_http_request(),
|
||||||
|
}
|
||||||
|
|
||||||
|
### load notification channel implementations
|
||||||
|
notification_channel_implementations = {
|
||||||
|
"console": implementation_notification_channel_console(),
|
||||||
|
"email": implementation_notification_channel_email(),
|
||||||
|
}
|
||||||
|
|
||||||
|
### get configuration data
|
||||||
|
checks = conf_normalize_root(check_kind_implementations, _json.loads(file_read(args.conf_path)))
|
||||||
|
if (args.expose_full_conf):
|
||||||
|
_sys.stdout.write(_json.dumps(checks, indent = "\t") + "\n")
|
||||||
|
_sys.exit(1)
|
||||||
|
else:
|
||||||
|
### get state data
|
||||||
|
if (not _os.path.exists(args.state_path)):
|
||||||
|
state_data = {}
|
||||||
|
file_write(args.state_path, _json.dumps(state_data, indent = "\t"))
|
||||||
|
else:
|
||||||
|
state_data = _json.loads(file_read(args.state_path))
|
||||||
|
|
||||||
|
### iterate through checks
|
||||||
|
for (check_name, check_data, ) in checks.items():
|
||||||
|
if (not check_data["active"]):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
### get old state and examine whether the check shall be executed
|
||||||
|
old_item_state = (
|
||||||
|
None
|
||||||
|
if (check_name not in state_data) else
|
||||||
|
state_decode(state_data[check_name])
|
||||||
|
)
|
||||||
|
timestamp = get_current_timestamp()
|
||||||
|
due = (
|
||||||
|
(old_item_state is None)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(old_item_state["count"] is not None)
|
||||||
|
and
|
||||||
|
((timestamp - old_item_state["timestamp"]) >= (1 * 5))
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(
|
||||||
|
(check_data["schedule"]["kind"] == "minutely")
|
||||||
|
and
|
||||||
|
((timestamp - old_item_state["timestamp"]) >= (60))
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(check_data["schedule"]["kind"] == "hourly")
|
||||||
|
and
|
||||||
|
((timestamp - old_item_state["timestamp"]) >= (60 * 60))
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(check_data["schedule"]["kind"] == "daily")
|
||||||
|
and
|
||||||
|
((timestamp - old_item_state["timestamp"]) >= (60 * 60 * 24))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (not due):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
_sys.stderr.write(
|
||||||
|
string_coin(
|
||||||
|
"-- {{check_name}}\n",
|
||||||
|
{
|
||||||
|
"check_name": check_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
### execute check and set new state
|
||||||
|
result = check_kind_implementations[check_data["kind"]].run(check_data)
|
||||||
|
new_item_state = {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"condition": result["condition"],
|
||||||
|
"count": (
|
||||||
|
1
|
||||||
|
if (
|
||||||
|
(old_item_state is None)
|
||||||
|
or
|
||||||
|
(old_item_state["condition"] != result["condition"])
|
||||||
|
) else
|
||||||
|
(
|
||||||
|
(old_item_state["count"] + 1)
|
||||||
|
if (
|
||||||
|
(old_item_state["count"] is not None)
|
||||||
|
and
|
||||||
|
((old_item_state["count"] + 1) <= args.threshold)
|
||||||
|
) else
|
||||||
|
None
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
state_data[check_name] = state_encode(new_item_state)
|
||||||
|
file_write(args.state_path, _json.dumps(state_data, indent = "\t"))
|
||||||
|
|
||||||
|
### send notifications
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
(new_item_state["count"] is not None)
|
||||||
|
and
|
||||||
|
(new_item_state["count"] == args.threshold)
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
(new_item_state["count"] is None)
|
||||||
|
and
|
||||||
|
args.keep_notifying
|
||||||
|
)
|
||||||
|
):
|
||||||
|
for notification in check_data["notifications"]:
|
||||||
|
if (notification["kind"] in notification_channel_implementations):
|
||||||
|
notification_channel_implementations[notification["kind"]].notify(
|
||||||
|
notification["parameters"],
|
||||||
|
check_name,
|
||||||
|
check_data,
|
||||||
|
new_item_state,
|
||||||
|
result["output"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("invalid notification kind: %s" % notification["kind"])
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
9
source/packages.py
Normal file
9
source/packages.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import sys as _sys
|
||||||
|
import os as _os
|
||||||
|
import subprocess as _subprocess
|
||||||
|
import argparse as _argparse
|
||||||
|
import json as _json
|
||||||
|
import requests as _requests
|
||||||
|
import smtplib as _smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
4
todo.md
Normal file
4
todo.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
- prevent parallel acces to state file?
|
||||||
|
- more resililient checks
|
||||||
|
- self check
|
||||||
|
- notification channel "Matrix"
|
||||||
16
tools/build
Executable file
16
tools/build
Executable file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
mkdir -p build
|
||||||
|
echo "#!/usr/bin/env python3" > build/heimdall
|
||||||
|
cat \
|
||||||
|
source/packages.py \
|
||||||
|
source/lib.py \
|
||||||
|
source/interface_check_kind.py \
|
||||||
|
source/implementation_check_kind_script.py \
|
||||||
|
source/implementation_check_kind_http_request.py \
|
||||||
|
source/interface_notification_channel.py \
|
||||||
|
source/implementation_notification_channel_console.py \
|
||||||
|
source/implementation_notification_channel_email.py \
|
||||||
|
source/main.py \
|
||||||
|
>> build/heimdall
|
||||||
|
chmod +x build/heimdall
|
||||||
3
tools/install
Executable file
3
tools/install
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
cp build/heimdall /usr/local/bin/
|
||||||
Loading…
Reference in a new issue