forked from mxx1111/Homelab
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathactions.py
More file actions
131 lines (111 loc) · 5.46 KB
/
Copy pathactions.py
File metadata and controls
131 lines (111 loc) · 5.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
"""容器操作与快照分析。
容器的启停走宿主机 docker CLI(compose 已挂 docker.sock)。注意 socket 挂成
:ro 只限制 socket 文件本身的权限,通过它照样能执行任何 docker 动作——所以
必须在这里自己做保护名单,不能指望挂载选项。
快照这边只读:存储卷都是 :ro 挂载,容器内删不掉,也不打算改成 rw。
面板没有登录,给它删快照的权限风险远大于收益,改为生成命令让人工执行。
"""
import logging
import re
import subprocess
log = logging.getLogger("homelab.actions")
# 停了就没法从面板恢复,或者会连带把防护掀掉
ALWAYS_PROTECTED = {"homelab-dashboard", "crowdsec"}
VALID_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
ACTIONS = {"restart", "start", "stop"}
class ActionError(Exception):
"""带用户可读文案的操作失败"""
class Actions:
def __init__(self, cfg):
acfg = (cfg or {}).get("actions") or {}
self.enabled = bool(acfg.get("enabled", True))
from . import demo as demo_mod
self.demo = demo_mod.enabled(cfg)
extra = {str(x) for x in (acfg.get("protected_containers") or [])}
self.protected = ALWAYS_PROTECTED | extra
self.log_lines = int(acfg.get("log_lines", 200))
def _check(self, name, action):
if not self.enabled:
raise ActionError("容器操作已在 config.yaml 中禁用")
if not VALID_NAME.match(name or ""):
raise ActionError(f"非法的容器名: {name}")
if action not in ACTIONS:
raise ActionError(f"不支持的动作: {action}")
if name in self.protected and action in ("stop", "restart"):
raise ActionError(
f"{name} 在保护名单里,不允许从面板 {action}。"
"它停了面板就失去控制能力,需要时请 SSH 操作")
def container(self, name, action):
self._check(name, action)
if self.demo:
from . import demo as demo_mod
try:
demo_mod.container_action(name, action)
except KeyError:
raise ActionError(f"没有这个容器: {name}") from None
return {"ok": True, "container": name, "action": action,
"output": "演示模式:状态只改在内存里,每小时重置"}
try:
out = subprocess.run(["docker", action, name],
capture_output=True, text=True, timeout=90)
except subprocess.TimeoutExpired:
raise ActionError(f"{action} {name} 超时(90 秒)") from None
except OSError as exc:
raise ActionError(f"执行 docker 失败: {exc}") from None
if out.returncode != 0:
raise ActionError(out.stderr.strip()[:200] or f"docker {action} 失败")
log.info("容器 %s 执行 %s", name, action)
return {"ok": True, "container": name, "action": action,
"output": out.stdout.strip()[:200]}
def logs(self, name, lines=None):
if not VALID_NAME.match(name or ""):
raise ActionError(f"非法的容器名: {name}")
if self.demo:
from . import demo as demo_mod
return {"ok": True, "container": name, "lines": lines or self.log_lines,
"text": demo_mod.container_logs(name, lines)}
n = max(1, min(int(lines or self.log_lines), 1000))
try:
out = subprocess.run(
["docker", "logs", "--tail", str(n), "--timestamps", name],
capture_output=True, text=True, timeout=25)
except subprocess.TimeoutExpired:
raise ActionError("读取日志超时") from None
except OSError as exc:
raise ActionError(f"执行 docker 失败: {exc}") from None
if out.returncode != 0:
raise ActionError(out.stderr.strip()[:200] or "读取日志失败")
# docker 把大部分服务日志写在 stderr,两股合并按时间看才完整
text = (out.stdout or "") + (out.stderr or "")
return {"ok": True, "container": name, "lines": n,
"text": text[-60000:]}
# ---------- 快照 ----------
SNAP_TIME = re.compile(r"(20\d{2})[-_]?(\d{2})[-_]?(\d{2})[-_ T]?(\d{2})?(\d{2})?")
def _parse_when(path):
m = SNAP_TIME.search(path)
if not m:
return None
y, mo, d, h, mi = m.groups()
return f"{y}-{mo}-{d}" + (f" {h}:{mi}" if h and mi else "")
def snapshot_plan(cfg, sections, keep=10):
"""按卷分组列出快照,标出超过保留数的那些,并给出可复制的清理命令。
真删除有意不做:卷是只读挂载,且面板无认证。
"""
storage = (sections.get("storage") or {}).get("data") or {}
groups = []
for vol in storage.get("volumes") or []:
snaps = vol.get("snapshots")
if not snaps:
continue
items = [{"path": s, "when": _parse_when(s)} for s in snaps]
items.sort(key=lambda x: (x["when"] or "", x["path"]), reverse=True)
stale = items[keep:]
groups.append({
"label": vol.get("label"), "mount": vol.get("path"),
"total": len(items), "keep": keep,
"recent": items[:keep], "stale": stale,
"command": " && ".join(
f"btrfs subvolume delete {s['path']}" for s in stale) or None,
})
return {"ok": True, "groups": groups,
"note": "卷为只读挂载,面板不执行删除。复制命令到宿主机上以 root 执行"}