refactor: separate monitoring from hardware plugins
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
sys.path.append("/usr/local/share/pigway-pi-control")
|
||||
from plugin_api import discover,registered,request as plugin_request,socket_path
|
||||
import configparser,ipaddress,json,re,secrets,shutil,subprocess,threading,time,socket
|
||||
from concurrent.futures import ThreadPoolExecutor,as_completed
|
||||
from http import HTTPStatus
|
||||
@@ -12,7 +15,7 @@ CFG=Path("/etc/pigway-pi-control.conf")
|
||||
STATUS=Path("/run/pigway-pi-control/status.json")
|
||||
TOKEN=Path("/etc/pigway-pi-control-api.token")
|
||||
WEB=Path("/usr/local/share/pigway-pi-control/web/index.html")
|
||||
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","fan","led","timing","dark_mode","display","api"}
|
||||
ALLOWED_SECTIONS={"device","hosts","services","processes","alerts","timing","dark_mode","api"}
|
||||
NAME_RE=re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||
UNIT_RE=re.compile(r"^[A-Za-z0-9_.@:-]{1,128}\.service$")
|
||||
RGB_KEY_RE=re.compile(r"^(cpu|power|memory|storage|network|service|normal)_[rgb]$")
|
||||
@@ -21,6 +24,40 @@ NORMAL_MODES={"off","solid","flash","flow","breathe","marquee","rainbow","colorf
|
||||
EFFECT_COLORS={"red","green","blue","yellow","purple","cyan","white"}
|
||||
PROTECTED_UNITS={"pigway-pi-control-api.service"}
|
||||
|
||||
def plugin_inventory():
|
||||
cfg=load_cfg();items=discover(cfg)
|
||||
for item in items:item["paused"]=cfg.getboolean("plugin_paused",item["id"],fallback=False)
|
||||
return items
|
||||
|
||||
|
||||
def find_plugin(plugin_id):
|
||||
for item in plugin_inventory():
|
||||
if item["id"]==plugin_id:return item
|
||||
raise ValueError("unknown plugin")
|
||||
|
||||
|
||||
def plugin_operation(body):
|
||||
plugin_id=str(body.get("id",""))
|
||||
if not NAME_RE.fullmatch(plugin_id):raise ValueError("invalid plugin id")
|
||||
item=find_plugin(plugin_id);operation=body.get("operation")
|
||||
if operation=="link":
|
||||
if not item["online"]:raise ValueError("plugin is offline")
|
||||
text=replace_ini_value("plugins",plugin_id,socket_path(item["socket"]))
|
||||
save_ini_text(text,"plugin-link")
|
||||
text=replace_ini_value("plugin_paused",plugin_id,"false")
|
||||
elif operation in {"pause","resume","unlink"}:
|
||||
if not item["linked"]:raise ValueError("plugin is not linked")
|
||||
text=replace_ini_value("plugins",plugin_id,None) if operation=="unlink" else replace_ini_value("plugin_paused",plugin_id,"true" if operation=="pause" else "false")
|
||||
else:raise ValueError("invalid plugin operation")
|
||||
backup=save_ini_text(text,"plugin")
|
||||
# Only reload a monitor that is already running. Never start hardware here.
|
||||
subprocess.run(["systemctl","try-restart","pigway-pi-control.service"],check=True,timeout=15)
|
||||
if operation in {"pause","unlink"}:
|
||||
try:plugin_request(item["socket"],"DELETE","/v1/state",{})
|
||||
except Exception:pass # An offline plugin expires its lease independently.
|
||||
return {"ok":True,"backup":backup}
|
||||
|
||||
|
||||
def machine_identity():
|
||||
cfg=load_cfg(); identifier=cfg.get("device","identifier",fallback="auto").strip()
|
||||
if not identifier or identifier.lower()=="auto": identifier=socket.gethostname()
|
||||
@@ -92,12 +129,19 @@ def api_setting(key,default,cast=str):
|
||||
|
||||
def config_json():
|
||||
cfg=load_cfg()
|
||||
return {section:dict(cfg.items(section)) for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||
return {section:{k:v for k,v in cfg.items(section) if not (section=="timing" and k in {"oled_refresh_interval","page_interval"})} for section in cfg.sections() if section in ALLOWED_SECTIONS}
|
||||
|
||||
def validate_value(section,key,value,existing):
|
||||
if section=="timing" and key in {"oled_refresh_interval","page_interval"}:raise ValueError("display timing is managed by the hardware plugin")
|
||||
if section not in {"services","processes","hosts"} and key not in existing.get(section,set()):
|
||||
raise ValueError(f"unsupported option: {section}.{key}")
|
||||
if RGB_KEY_RE.fullmatch(key):
|
||||
if section=="hardware" and key=="cooling_hat_enabled":
|
||||
if value.lower() not in ("true","false"): raise ValueError("cooling_hat_enabled must be true or false")
|
||||
elif section=="led" and key=="alert_mode":
|
||||
if value.lower() not in ("breathe","flash"): raise ValueError("alert_mode must be breathe or flash")
|
||||
elif section=="led" and key=="custom_min_hold_ms":
|
||||
if not 1500<=int(value)<=60000: raise ValueError("custom_min_hold_ms must be 1500..60000")
|
||||
elif RGB_KEY_RE.fullmatch(key):
|
||||
number=int(value)
|
||||
if not 0<=number<=255: raise ValueError(f"{key} must be 0..255")
|
||||
elif FLASH_KEY_RE.fullmatch(key):
|
||||
@@ -124,14 +168,14 @@ def validate_value(section,key,value,existing):
|
||||
|
||||
def replace_ini_value(section,key,value):
|
||||
text=CFG.read_text()
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
return text
|
||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
if key_match:
|
||||
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||
return text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||
@@ -169,11 +213,11 @@ def update_ini(updates):
|
||||
if not value or len(value)>256 or any(c in value for c in "\r\n\x00"):
|
||||
raise ValueError(f"invalid value: {section}.{key}")
|
||||
validate_value(section,str(key),value,existing)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
continue
|
||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^(\s*{re.escape(str(key))}\s*=\s*).*$",chunk)
|
||||
@@ -308,14 +352,14 @@ def edit_service_monitor(operation,name,target,note="",previous_name=""):
|
||||
text=CFG.read_text()
|
||||
def change(section,key,value):
|
||||
nonlocal text
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\]\s*$",text)
|
||||
section_match=re.search(rf"(?mi)^\[{re.escape(section)}\][ \t]*$",text)
|
||||
if not section_match:
|
||||
if value is not None: text=text.rstrip()+f"\n\n[{section}]\n{key} = {value}\n"
|
||||
return
|
||||
next_section=re.search(r"(?m)^\[.+\]\s*$",text[section_match.end():])
|
||||
next_section=re.search(r"(?m)^\[.+\][ \t]*$",text[section_match.end():])
|
||||
end=section_match.end()+(next_section.start() if next_section else len(text)-section_match.end())
|
||||
chunk=text[section_match.end():end]
|
||||
key_match=re.search(rf"(?mi)^\s*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
key_match=re.search(rf"(?mi)^[ \t]*{re.escape(key)}\s*=.*(?:\n|$)",chunk)
|
||||
if key_match:
|
||||
start=section_match.end()+key_match.start(); stop=section_match.end()+key_match.end()
|
||||
text=text[:start]+((f"{key} = {value}\n") if value is not None else "")+text[stop:]
|
||||
@@ -408,6 +452,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if parsed.path=="/api/v1/fleet/logs":
|
||||
self.send_json(200,fleet_journal_query(since,until,severity,event,search,value("machine"),sort,order,limit,offset)); return
|
||||
self.send_json(200,journal_query(since,until,severity,event,search,sort,order,limit,offset)); return
|
||||
if parsed.path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
query=parse_qs(parsed.query);host_id=query.get("host",[""])[0]
|
||||
plugin_id=query.get("id",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id)
|
||||
self.send_json(200,remote_json(host["url"],parsed.path+"?"+urlencode({"id":plugin_id})));return
|
||||
if parsed.path.endswith("/plugins"):
|
||||
self.send_json(200,{"plugins":plugin_inventory()});return
|
||||
item=find_plugin(plugin_id)
|
||||
self.send_json(200,plugin_request(item["socket"],"GET","/v1/config"));return
|
||||
if parsed.path=="/api/v1/config":
|
||||
query=parse_qs(parsed.query); host_id=query.get("host",[""])[0]
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
@@ -424,10 +478,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_PUT(self):
|
||||
path=urlparse(self.path).path
|
||||
if path not in {"/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return
|
||||
if path not in {"/api/v1/plugins","/api/v1/plugin/config","/api/v1/config","/api/v1/services","/api/v1/services/control","/api/v1/hosts","/api/v1/fleet/config"}: self.send_json(404,{"error":"not found"}); return
|
||||
if not self.authorized(): self.send_json(401,{"error":"bearer token required"}); return
|
||||
try:
|
||||
body=self.read_json()
|
||||
if path in {"/api/v1/plugins","/api/v1/plugin/config"}:
|
||||
host_id=str(body.get("host",""))
|
||||
if host_id and host_id!=machine_identity()["id"]:
|
||||
host=find_host(host_id);token=str(body.get("target_token",""))
|
||||
if not token:raise ValueError("remote bearer token required")
|
||||
payload={k:v for k,v in body.items() if k not in {"host","target_token"}}
|
||||
self.send_json(200,remote_json(host["url"],path,"PUT",payload,token));return
|
||||
if path.endswith("/plugins"):self.send_json(200,plugin_operation(body));return
|
||||
item=find_plugin(str(body.get("id","")))
|
||||
self.send_json(200,plugin_request(item["socket"],"PUT","/v1/config",{"updates":body.get("updates")}));return
|
||||
if path=="/api/v1/hosts":
|
||||
backup=edit_host(str(body.get("operation","")),str(body.get("id","")),str(body.get("url","")))
|
||||
self.send_json(200,{"ok":True,"backup":backup,"hosts":host_registry()}); return
|
||||
|
||||
Reference in New Issue
Block a user