serverwatcher 2.2.4__tar.gz → 3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {serverwatcher-2.2.4/src/serverwatcher.egg-info → serverwatcher-3.1}/PKG-INFO +1 -1
- {serverwatcher-2.2.4 → serverwatcher-3.1}/pyproject.toml +1 -1
- {serverwatcher-2.2.4 → serverwatcher-3.1}/src/serverwatcher/__init__.py +3 -0
- serverwatcher-3.1/src/serverwatcher/config/global.py +7 -0
- serverwatcher-3.1/src/serverwatcher/config/messages.py +25 -0
- serverwatcher-3.1/src/serverwatcher/config/watcher.py +32 -0
- serverwatcher-3.1/src/serverwatcher/schema.py +45 -0
- serverwatcher-3.1/src/serverwatcher/watcher.py +280 -0
- {serverwatcher-2.2.4 → serverwatcher-3.1/src/serverwatcher.egg-info}/PKG-INFO +1 -1
- {serverwatcher-2.2.4 → serverwatcher-3.1}/src/serverwatcher.egg-info/SOURCES.txt +5 -3
- serverwatcher-2.2.4/src/serverwatcher/config.py +0 -33
- serverwatcher-2.2.4/src/serverwatcher/messages.py +0 -43
- serverwatcher-2.2.4/src/serverwatcher/watcher.py +0 -263
- {serverwatcher-2.2.4 → serverwatcher-3.1}/LICENSE +0 -0
- {serverwatcher-2.2.4 → serverwatcher-3.1}/README.md +0 -0
- {serverwatcher-2.2.4 → serverwatcher-3.1}/setup.cfg +0 -0
- {serverwatcher-2.2.4 → serverwatcher-3.1}/src/serverwatcher.egg-info/dependency_links.txt +0 -0
- {serverwatcher-2.2.4 → serverwatcher-3.1}/src/serverwatcher.egg-info/requires.txt +0 -0
- {serverwatcher-2.2.4 → serverwatcher-3.1}/src/serverwatcher.egg-info/top_level.txt +0 -0
|
@@ -9,6 +9,7 @@ except PackageNotFoundError:
|
|
|
9
9
|
from .watcher import ServerWatcher
|
|
10
10
|
from .config import WatcherConfig
|
|
11
11
|
from .messages import WatcherMessages
|
|
12
|
+
from .schema import validate_config_schema, validate_messages_schema
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
__all__ = [
|
|
@@ -16,4 +17,6 @@ __all__ = [
|
|
|
16
17
|
'ServerWatcher',
|
|
17
18
|
'WatcherConfig',
|
|
18
19
|
'WatcherMessages',
|
|
20
|
+
'validate_config_schema',
|
|
21
|
+
'validate_messages_schema',
|
|
19
22
|
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
@dataclass
|
|
4
|
+
class MessagesConfig:
|
|
5
|
+
prefix: str
|
|
6
|
+
broadcast_restart_at: str
|
|
7
|
+
broadcast_minute: dict = field(default_factory=dict)
|
|
8
|
+
broadcast_second: dict = field(default_factory=dict)
|
|
9
|
+
|
|
10
|
+
log_start: str = ""
|
|
11
|
+
log_validation_fail: str = ""
|
|
12
|
+
log_validation_ok: str = ""
|
|
13
|
+
log_immediate_restart: str = ""
|
|
14
|
+
log_no_restart: str = ""
|
|
15
|
+
log_scheduled: str = ""
|
|
16
|
+
log_gap_low: str = ""
|
|
17
|
+
log_gap_high: str = ""
|
|
18
|
+
|
|
19
|
+
reason_restart_soon: str = ""
|
|
20
|
+
reason_ram: str = ""
|
|
21
|
+
reason_cpu: str = ""
|
|
22
|
+
reason_uptime: str = ""
|
|
23
|
+
reason_tps: str = ""
|
|
24
|
+
reason_low_uptime: str = ""
|
|
25
|
+
reason_players: str = ""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
@dataclass
|
|
4
|
+
class WatcherConfig:
|
|
5
|
+
restart_soon_schedule_id: int
|
|
6
|
+
origin_disable_schedule_id: int
|
|
7
|
+
|
|
8
|
+
ram_threshold: int
|
|
9
|
+
cpu_threshold: int
|
|
10
|
+
uptime_hours_threshold: int
|
|
11
|
+
tps_threshold: float
|
|
12
|
+
|
|
13
|
+
weight_restart_soon: int
|
|
14
|
+
weight_ram: int
|
|
15
|
+
weight_cpu: int
|
|
16
|
+
weight_uptime: int
|
|
17
|
+
weight_tps: int
|
|
18
|
+
|
|
19
|
+
weight_low_uptime: int
|
|
20
|
+
weight_per_player: int
|
|
21
|
+
|
|
22
|
+
low_gap_minutes: int
|
|
23
|
+
high_gap_minutes: int
|
|
24
|
+
|
|
25
|
+
restart_wait_seconds: int
|
|
26
|
+
restart_online_timeout: int
|
|
27
|
+
restart_online_interval: int
|
|
28
|
+
|
|
29
|
+
logger_name_template: str
|
|
30
|
+
log_path: str
|
|
31
|
+
console_backspaces: int
|
|
32
|
+
timezone: str
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Dict, Any, List
|
|
2
|
+
|
|
3
|
+
from hungerlib.addons import validate_required_keys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_config_schema(raw: Dict[str, Any]) -> list[str]:
|
|
7
|
+
errors: List[str] = []
|
|
8
|
+
|
|
9
|
+
# top-level sections
|
|
10
|
+
errors += validate_required_keys(raw, ["panel", "origin", "server", "watcher"], "root")
|
|
11
|
+
|
|
12
|
+
panel = raw.get("panel", {})
|
|
13
|
+
origin = raw.get("origin", {})
|
|
14
|
+
server = raw.get("server", {})
|
|
15
|
+
watcher = raw.get("watcher", {})
|
|
16
|
+
|
|
17
|
+
errors += validate_required_keys(panel, ["name", "url", "api_key"], "panel")
|
|
18
|
+
errors += validate_required_keys(origin, ["server_id"], "origin")
|
|
19
|
+
errors += validate_required_keys(
|
|
20
|
+
server,
|
|
21
|
+
["name", "server_id", "domain", "port", "rcon_port", "rcon_password", "tps_command"],
|
|
22
|
+
"server",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# simple sanity checks (optional but nice)
|
|
26
|
+
if "port" in server and not isinstance(server["port"], int):
|
|
27
|
+
errors.append("[server] 'port' must be an integer")
|
|
28
|
+
if "rcon_port" in server and not isinstance(server["rcon_port"], int):
|
|
29
|
+
errors.append("[server] 'rcon_port' must be an integer")
|
|
30
|
+
|
|
31
|
+
# watcher numeric sanity
|
|
32
|
+
if "ram_threshold" in watcher and watcher["ram_threshold"] <= 0:
|
|
33
|
+
errors.append("[watcher] 'ram_threshold' must be > 0")
|
|
34
|
+
if "cpu_threshold" in watcher and watcher["cpu_threshold"] <= 0:
|
|
35
|
+
errors.append("[watcher] 'cpu_threshold' must be > 0")
|
|
36
|
+
|
|
37
|
+
return errors
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_messages_schema(raw: Dict[str, Any]) -> list[str]:
|
|
41
|
+
# you can make this as strict as you want; for now just ensure prefix exists
|
|
42
|
+
errors: List[str] = []
|
|
43
|
+
if "prefix" not in raw:
|
|
44
|
+
errors.append("[messages] Missing required key: 'prefix'")
|
|
45
|
+
return errors
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from zoneinfo import ZoneInfo
|
|
4
|
+
|
|
5
|
+
from hungerlib import Panel, HungerLogger
|
|
6
|
+
from hungerlib.servers import MinecraftServer, GenericServer
|
|
7
|
+
from hungerlib.addons import (
|
|
8
|
+
clearTerminal,
|
|
9
|
+
Snapshot,
|
|
10
|
+
snapSchedule,
|
|
11
|
+
waitForOnline,
|
|
12
|
+
validateAll,
|
|
13
|
+
runCountdownEvents,
|
|
14
|
+
load_yaml,
|
|
15
|
+
map_to_dataclass,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# NEW CONFIG DATACLASSES
|
|
19
|
+
from config.global import GlobalConfig
|
|
20
|
+
from config.messages import MessagesConfig
|
|
21
|
+
from config.watcher import WatcherConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------
|
|
25
|
+
# Utility: load config or copy default from defaultconfigs/
|
|
26
|
+
# ---------------------------------------------------------
|
|
27
|
+
def load_or_default(path: str, default_path: str, schema):
|
|
28
|
+
"""
|
|
29
|
+
Loads YAML from path. If missing, copies default_path → path.
|
|
30
|
+
Then maps YAML → dataclass.
|
|
31
|
+
"""
|
|
32
|
+
if not os.path.exists(path):
|
|
33
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
34
|
+
with open(default_path, "r") as src, open(path, "w") as dst:
|
|
35
|
+
dst.write(src.read())
|
|
36
|
+
|
|
37
|
+
raw = load_yaml(path)
|
|
38
|
+
return map_to_dataclass(raw, schema)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------
|
|
42
|
+
# Main Watcher
|
|
43
|
+
# ---------------------------------------------------------
|
|
44
|
+
class ServerWatcher:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
# Load all 3 configs
|
|
47
|
+
self.global_cfg: GlobalConfig = load_or_default(
|
|
48
|
+
"config/global.yaml",
|
|
49
|
+
"defaultconfigs/global.yaml",
|
|
50
|
+
GlobalConfig
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.messages: MessagesConfig = load_or_default(
|
|
54
|
+
"config/messages.yaml",
|
|
55
|
+
"defaultconfigs/messages.yaml",
|
|
56
|
+
MessagesConfig
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self.cfg: WatcherConfig = load_or_default(
|
|
60
|
+
"config/watcher.yaml",
|
|
61
|
+
"defaultconfigs/watcher.yaml",
|
|
62
|
+
WatcherConfig
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# PANEL
|
|
66
|
+
p = self.global_cfg.panel
|
|
67
|
+
self.panel = Panel(
|
|
68
|
+
name=p["name"],
|
|
69
|
+
url=p["url"],
|
|
70
|
+
api_key=p["api_key"],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# ORIGIN
|
|
74
|
+
o = self.global_cfg.origin
|
|
75
|
+
self.origin = GenericServer(
|
|
76
|
+
name="Origin",
|
|
77
|
+
panel=self.panel,
|
|
78
|
+
server_id=o["server_id"],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# SERVER
|
|
82
|
+
s = self.global_cfg.server
|
|
83
|
+
self.server = MinecraftServer(
|
|
84
|
+
name=s["name"],
|
|
85
|
+
panel=self.panel,
|
|
86
|
+
server_id=s["server_id"],
|
|
87
|
+
server_domain=s["domain"],
|
|
88
|
+
server_port=s["port"],
|
|
89
|
+
rcon_port=s["rcon_port"],
|
|
90
|
+
rcon_password=s["rcon_password"],
|
|
91
|
+
tpsCommand=s["tps_command"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# LOGGER (fully configurable)
|
|
95
|
+
logger_name = self.cfg.logger_name_template.format(
|
|
96
|
+
server_name=s["name"]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
self.log = HungerLogger(
|
|
100
|
+
name=logger_name,
|
|
101
|
+
server=self.server,
|
|
102
|
+
log_path=self.cfg.log_path,
|
|
103
|
+
console_backspaces=self.cfg.console_backspaces,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# TIMEZONE
|
|
107
|
+
self.tz = ZoneInfo(self.cfg.timezone)
|
|
108
|
+
|
|
109
|
+
# -----------------------------------------------------
|
|
110
|
+
# Utility: format messages with prefix
|
|
111
|
+
# -----------------------------------------------------
|
|
112
|
+
def fmt(self, template: str, **kwargs):
|
|
113
|
+
return template.format(prefix=self.messages.prefix, **kwargs)
|
|
114
|
+
|
|
115
|
+
# -----------------------------------------------------
|
|
116
|
+
# Shutdown
|
|
117
|
+
# -----------------------------------------------------
|
|
118
|
+
def shutdown(self):
|
|
119
|
+
self.log.info("Shutting down ServerWatcher.")
|
|
120
|
+
raise SystemExit
|
|
121
|
+
|
|
122
|
+
# -----------------------------------------------------
|
|
123
|
+
# Restart logic
|
|
124
|
+
# -----------------------------------------------------
|
|
125
|
+
def restart_and_wait(self):
|
|
126
|
+
self.origin.disableSchedule(self.cfg.restart_soon_schedule_id)
|
|
127
|
+
self.server.restart()
|
|
128
|
+
self.log.info("Restart action sent. Waiting...")
|
|
129
|
+
time.sleep(self.cfg.restart_wait_seconds)
|
|
130
|
+
|
|
131
|
+
self.log.warn("Checking server status...")
|
|
132
|
+
alive = waitForOnline(
|
|
133
|
+
self.server,
|
|
134
|
+
timeout=self.cfg.restart_online_timeout,
|
|
135
|
+
interval=self.cfg.restart_online_interval,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if alive:
|
|
139
|
+
self.log.info("Server is back online!")
|
|
140
|
+
self.server.sendBroadcast(
|
|
141
|
+
f"{self.messages.prefix}<green>Restart successful!"
|
|
142
|
+
)
|
|
143
|
+
self.origin.enableSchedule(self.cfg.origin_disable_schedule_id)
|
|
144
|
+
else:
|
|
145
|
+
self.log.error("Server failed to restart!")
|
|
146
|
+
|
|
147
|
+
# -----------------------------------------------------
|
|
148
|
+
# Schedule restart
|
|
149
|
+
# -----------------------------------------------------
|
|
150
|
+
def schedule_restart(self, minutes):
|
|
151
|
+
info = snapSchedule(minimumMinutes=minutes)
|
|
152
|
+
scheduled = info["scheduled"]
|
|
153
|
+
|
|
154
|
+
local_time = scheduled.astimezone(self.tz)
|
|
155
|
+
time_str = local_time.strftime("%I:%M %p")
|
|
156
|
+
|
|
157
|
+
self.server.sendBroadcast(
|
|
158
|
+
self.fmt(self.messages.broadcast_restart_at, time=time_str)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
minute_callbacks = {
|
|
162
|
+
m: (lambda msg=self.fmt(self.messages.broadcast_minute[m]):
|
|
163
|
+
self.server.sendBroadcast(msg))
|
|
164
|
+
for m in self.messages.broadcast_minute
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
second_callbacks = {
|
|
168
|
+
s: (lambda msg=self.fmt(self.messages.broadcast_second[s]):
|
|
169
|
+
self.server.sendBroadcast(msg))
|
|
170
|
+
for s in self.messages.broadcast_second
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
runCountdownEvents(
|
|
174
|
+
target_time=scheduled,
|
|
175
|
+
minute_callbacks=minute_callbacks,
|
|
176
|
+
second_callbacks=second_callbacks,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# -----------------------------------------------------
|
|
180
|
+
# Main evaluation logic
|
|
181
|
+
# -----------------------------------------------------
|
|
182
|
+
def evaluate(self):
|
|
183
|
+
self.log.info(self.messages.log_start)
|
|
184
|
+
|
|
185
|
+
if not validateAll(self.panel, self.server):
|
|
186
|
+
self.log.error(self.messages.log_validation_fail)
|
|
187
|
+
self.shutdown()
|
|
188
|
+
|
|
189
|
+
self.server.refresh()
|
|
190
|
+
snap = Snapshot(self.server, 2, True)
|
|
191
|
+
|
|
192
|
+
pro = 0
|
|
193
|
+
anti = 0
|
|
194
|
+
restart_reasons = []
|
|
195
|
+
no_restart_reasons = []
|
|
196
|
+
|
|
197
|
+
# PRO-RESTART
|
|
198
|
+
if self.server.getSchedule(self.cfg.restart_soon_schedule_id)["is_active"]:
|
|
199
|
+
restart_reasons.append(self.messages.reason_restart_soon)
|
|
200
|
+
pro += self.cfg.weight_restart_soon
|
|
201
|
+
|
|
202
|
+
if snap.ram >= self.cfg.ram_threshold:
|
|
203
|
+
restart_reasons.append(
|
|
204
|
+
self.fmt(self.messages.reason_ram, ram=snap.ram, threshold=self.cfg.ram_threshold)
|
|
205
|
+
)
|
|
206
|
+
pro += round(snap.ram, 0) - 5
|
|
207
|
+
|
|
208
|
+
if snap.cpu >= self.cfg.cpu_threshold:
|
|
209
|
+
restart_reasons.append(
|
|
210
|
+
self.fmt(self.messages.reason_cpu, cpu=snap.cpu, threshold=self.cfg.cpu_threshold)
|
|
211
|
+
)
|
|
212
|
+
pro += self.cfg.weight_cpu
|
|
213
|
+
|
|
214
|
+
if snap.uptime // 3600 >= self.cfg.uptime_hours_threshold:
|
|
215
|
+
restart_reasons.append(
|
|
216
|
+
self.fmt(self.messages.reason_uptime, uptime=snap.uptime_formatted,
|
|
217
|
+
threshold=self.cfg.uptime_hours_threshold)
|
|
218
|
+
)
|
|
219
|
+
pro += self.cfg.weight_uptime
|
|
220
|
+
|
|
221
|
+
if snap.tps <= self.cfg.tps_threshold:
|
|
222
|
+
restart_reasons.append(
|
|
223
|
+
self.fmt(self.messages.reason_tps, tps=snap.tps, threshold=self.cfg.tps_threshold)
|
|
224
|
+
)
|
|
225
|
+
pro += self.cfg.weight_tps
|
|
226
|
+
|
|
227
|
+
# ANTI-RESTART
|
|
228
|
+
if snap.uptime // 60 < 30:
|
|
229
|
+
no_restart_reasons.append(
|
|
230
|
+
self.fmt(self.messages.reason_low_uptime, uptime=snap.uptime_formatted)
|
|
231
|
+
)
|
|
232
|
+
anti += self.cfg.weight_low_uptime
|
|
233
|
+
|
|
234
|
+
if snap.players > 0:
|
|
235
|
+
verb = "are" if snap.players != 1 else "is"
|
|
236
|
+
plural = "players" if snap.players != 1 else "player"
|
|
237
|
+
no_restart_reasons.append(
|
|
238
|
+
self.fmt(self.messages.reason_players, verb=verb, count=snap.players, plural=plural)
|
|
239
|
+
)
|
|
240
|
+
anti += snap.players * self.cfg.weight_per_player
|
|
241
|
+
|
|
242
|
+
# LOGGING
|
|
243
|
+
for r in restart_reasons:
|
|
244
|
+
self.log.warn(f"- {r}")
|
|
245
|
+
for r in no_restart_reasons:
|
|
246
|
+
self.log.warn(f"- {r}")
|
|
247
|
+
|
|
248
|
+
self.log.warn(f"Pro-restart: {pro}")
|
|
249
|
+
self.log.warn(f"Anti-restart: {anti}")
|
|
250
|
+
|
|
251
|
+
gap = abs(pro - anti)
|
|
252
|
+
|
|
253
|
+
if pro == 0:
|
|
254
|
+
self.log.info(self.messages.log_no_restart)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
if pro > anti and snap.players == 0:
|
|
258
|
+
self.log.info(self.messages.log_immediate_restart)
|
|
259
|
+
self.restart_and_wait()
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
self.log.info(self.messages.log_scheduled)
|
|
263
|
+
|
|
264
|
+
if gap <= 2:
|
|
265
|
+
self.log.warn(self.fmt(self.messages.log_gap_low, gap=gap))
|
|
266
|
+
self.schedule_restart(self.cfg.low_gap_minutes)
|
|
267
|
+
else:
|
|
268
|
+
self.log.warn(self.fmt(self.messages.log_gap_high, gap=gap))
|
|
269
|
+
self.schedule_restart(self.cfg.high_gap_minutes)
|
|
270
|
+
|
|
271
|
+
self.restart_and_wait()
|
|
272
|
+
|
|
273
|
+
# -----------------------------------------------------
|
|
274
|
+
# Main loop
|
|
275
|
+
# -----------------------------------------------------
|
|
276
|
+
def run(self):
|
|
277
|
+
clearTerminal()
|
|
278
|
+
while True:
|
|
279
|
+
self.evaluate()
|
|
280
|
+
time.sleep(60)
|
|
@@ -2,11 +2,13 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/serverwatcher/__init__.py
|
|
5
|
-
src/serverwatcher/
|
|
6
|
-
src/serverwatcher/messages.py
|
|
5
|
+
src/serverwatcher/schema.py
|
|
7
6
|
src/serverwatcher/watcher.py
|
|
8
7
|
src/serverwatcher.egg-info/PKG-INFO
|
|
9
8
|
src/serverwatcher.egg-info/SOURCES.txt
|
|
10
9
|
src/serverwatcher.egg-info/dependency_links.txt
|
|
11
10
|
src/serverwatcher.egg-info/requires.txt
|
|
12
|
-
src/serverwatcher.egg-info/top_level.txt
|
|
11
|
+
src/serverwatcher.egg-info/top_level.txt
|
|
12
|
+
src/serverwatcher/config/global.py
|
|
13
|
+
src/serverwatcher/config/messages.py
|
|
14
|
+
src/serverwatcher/config/watcher.py
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
2
|
-
|
|
3
|
-
@dataclass
|
|
4
|
-
class WatcherConfig:
|
|
5
|
-
# schedule IDs
|
|
6
|
-
restart_soon_schedule_id: int = 13
|
|
7
|
-
origin_disable_schedule_id: int = 11
|
|
8
|
-
|
|
9
|
-
# thresholds
|
|
10
|
-
ram_threshold: int = 6
|
|
11
|
-
cpu_threshold: int = 150
|
|
12
|
-
uptime_hours_threshold: int = 12
|
|
13
|
-
tps_threshold: float = 19.5
|
|
14
|
-
|
|
15
|
-
# weights (pro)
|
|
16
|
-
weight_restart_soon: int = 3
|
|
17
|
-
weight_ram: int = 1
|
|
18
|
-
weight_cpu: int = 1
|
|
19
|
-
weight_uptime: int = 1
|
|
20
|
-
weight_tps: int = 1
|
|
21
|
-
|
|
22
|
-
# weights (anti)
|
|
23
|
-
weight_low_uptime: int = 5
|
|
24
|
-
weight_per_player: int = 1
|
|
25
|
-
|
|
26
|
-
# scheduling
|
|
27
|
-
low_gap_minutes: int = 120
|
|
28
|
-
high_gap_minutes: int = 60
|
|
29
|
-
|
|
30
|
-
# restart timing
|
|
31
|
-
restart_wait_seconds: int = 45
|
|
32
|
-
restart_online_timeout: int = 120
|
|
33
|
-
restart_online_interval: int = 2
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass, field
|
|
2
|
-
|
|
3
|
-
@dataclass
|
|
4
|
-
class WatcherMessages:
|
|
5
|
-
prefix: str = "<aqua>[Server Watcher]"
|
|
6
|
-
|
|
7
|
-
# broadcast templates
|
|
8
|
-
broadcast_restart_at: str = "{prefix} The server will restart at {time} CDT."
|
|
9
|
-
|
|
10
|
-
broadcast_minute: dict = field(default_factory=lambda: {
|
|
11
|
-
120: "{prefix} Restart in 2 hours!",
|
|
12
|
-
60: "{prefix} Restart in 1 hour!",
|
|
13
|
-
45: "{prefix} Restart in 45 minutes!",
|
|
14
|
-
30: "{prefix} Restart in 30 minutes!",
|
|
15
|
-
15: "{prefix} Restart in 15 minutes!",
|
|
16
|
-
5: "{prefix} Restart in 5 minutes!",
|
|
17
|
-
1: "{prefix} Restart in 1 minute!",
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
broadcast_second: dict = field(default_factory=lambda: {
|
|
21
|
-
s: "{prefix} Restart in " + str(s) + " seconds!"
|
|
22
|
-
for s in range(10, 0, -1)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
# log messages
|
|
26
|
-
log_start: str = "ServerWatcher is running!"
|
|
27
|
-
log_validation_fail: str = "Validation FAILED"
|
|
28
|
-
log_validation_ok: str = "All validation checks succeeded."
|
|
29
|
-
log_immediate_restart: str = "Restarting immediately."
|
|
30
|
-
log_no_restart: str = "The server does not need to restart."
|
|
31
|
-
log_scheduled: str = "Restart needed, but anti-restart factors outweigh it."
|
|
32
|
-
log_gap_low: str = "Gap {gap}. Scheduling restart in 2 hours."
|
|
33
|
-
log_gap_high: str = "Gap {gap}. Scheduling restart in 1 hour."
|
|
34
|
-
|
|
35
|
-
# reason messages
|
|
36
|
-
reason_restart_soon: str = "The server is set to restart soon"
|
|
37
|
-
reason_ram: str = "RAM usage ({ram}) is higher than {threshold} GB"
|
|
38
|
-
reason_cpu: str = "CPU usage ({cpu}) is higher than {threshold}%"
|
|
39
|
-
reason_uptime: str = "Uptime {uptime} exceeds {threshold}h"
|
|
40
|
-
reason_tps: str = "TPS {tps} is lower than {threshold}"
|
|
41
|
-
|
|
42
|
-
reason_low_uptime: str = "Uptime {uptime} is shorter than 30m"
|
|
43
|
-
reason_players: str = "There {verb} {count} {plural} online"
|
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
import time
|
|
2
|
-
from zoneinfo import ZoneInfo
|
|
3
|
-
|
|
4
|
-
from hungerlib import Panel, HungerLogger
|
|
5
|
-
from hungerlib.servers import MinecraftServer, GenericServer
|
|
6
|
-
from hungerlib.addons import (
|
|
7
|
-
clearTerminal,
|
|
8
|
-
Snapshot,
|
|
9
|
-
snapSchedule,
|
|
10
|
-
waitForOnline,
|
|
11
|
-
validateAll,
|
|
12
|
-
runCountdownEvents,
|
|
13
|
-
ensure_yaml,
|
|
14
|
-
load_yaml,
|
|
15
|
-
map_to_dataclass
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
from .config import WatcherConfig
|
|
19
|
-
from .messages import WatcherMessages
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
DEFAULT_CONFIG = {
|
|
23
|
-
"panel": {
|
|
24
|
-
"name": "My Panel",
|
|
25
|
-
"url": "https://example.com",
|
|
26
|
-
"api_key": "CHANGE_ME",
|
|
27
|
-
},
|
|
28
|
-
"origin": {
|
|
29
|
-
"server_id": "CHANGE_ME",
|
|
30
|
-
},
|
|
31
|
-
"server": {
|
|
32
|
-
"name": "My SMP",
|
|
33
|
-
"server_id": "CHANGE_ME",
|
|
34
|
-
"domain": "example.com",
|
|
35
|
-
"port": 25565,
|
|
36
|
-
"rcon_port": 25575,
|
|
37
|
-
"rcon_password": "password",
|
|
38
|
-
"tps_command": "ticks",
|
|
39
|
-
},
|
|
40
|
-
"watcher": {},
|
|
41
|
-
"messages": {},
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class ServerWatcher:
|
|
46
|
-
def __init__(self, config_path: str = "config.yaml"):
|
|
47
|
-
# ensure config exists, then load it
|
|
48
|
-
ensure_yaml(config_path, DEFAULT_CONFIG)
|
|
49
|
-
raw = load_yaml(config_path)
|
|
50
|
-
|
|
51
|
-
# map watcher + messages sections into your dataclasses
|
|
52
|
-
self.cfg: WatcherConfig = map_to_dataclass(raw.get("watcher", {}), WatcherConfig)
|
|
53
|
-
self.msg: WatcherMessages = map_to_dataclass(
|
|
54
|
-
raw.get("messages", {}), WatcherMessages
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
# panel / origin / server wiring from YAML
|
|
58
|
-
p = raw["panel"]
|
|
59
|
-
self.panel = Panel(
|
|
60
|
-
name=p["name"],
|
|
61
|
-
url=p["url"],
|
|
62
|
-
api_key=p["api_key"],
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
o = raw["origin"]
|
|
66
|
-
self.origin = GenericServer(
|
|
67
|
-
name="Origin",
|
|
68
|
-
panel=self.panel,
|
|
69
|
-
server_id=o["server_id"],
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
s = raw["server"]
|
|
73
|
-
self.server = MinecraftServer(
|
|
74
|
-
name=s["name"],
|
|
75
|
-
panel=self.panel,
|
|
76
|
-
server_id=s["server_id"],
|
|
77
|
-
server_domain=s["domain"],
|
|
78
|
-
server_port=s["port"],
|
|
79
|
-
rcon_port=s["rcon_port"],
|
|
80
|
-
rcon_password=s["rcon_password"],
|
|
81
|
-
tpsCommand=s["tps_command"],
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
self.log = HungerLogger(
|
|
85
|
-
name=f"ServerWatcher-{s['name']}",
|
|
86
|
-
server=self.server,
|
|
87
|
-
log_path="/home/container/logs/",
|
|
88
|
-
console_backspaces=8,
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
def fmt(self, template: str, **kwargs):
|
|
92
|
-
return template.format(prefix=self.msg.prefix, **kwargs)
|
|
93
|
-
|
|
94
|
-
def shutdown(self):
|
|
95
|
-
self.log.info("Shutting down ServerWatcher.")
|
|
96
|
-
raise SystemExit
|
|
97
|
-
|
|
98
|
-
def restart_and_wait(self):
|
|
99
|
-
self.origin.disableSchedule(self.cfg.origin_disable_schedule_id)
|
|
100
|
-
self.server.restart()
|
|
101
|
-
self.log.info("Restart action sent. Waiting...")
|
|
102
|
-
time.sleep(self.cfg.restart_wait_seconds)
|
|
103
|
-
|
|
104
|
-
self.log.warn("Checking server status...")
|
|
105
|
-
alive = waitForOnline(
|
|
106
|
-
self.server,
|
|
107
|
-
timeout=self.cfg.restart_online_timeout,
|
|
108
|
-
interval=self.cfg.restart_online_interval,
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
if alive:
|
|
112
|
-
self.log.info("Server is back online!")
|
|
113
|
-
self.server.sendBroadcast(f"{self.msg.prefix}<green>Restart successful!")
|
|
114
|
-
self.origin.enableSchedule(self.cfg.origin_disable_schedule_id)
|
|
115
|
-
else:
|
|
116
|
-
self.log.error("Server failed to restart!")
|
|
117
|
-
|
|
118
|
-
def schedule_restart(self, minutes):
|
|
119
|
-
info = snapSchedule(minimumMinutes=minutes)
|
|
120
|
-
scheduled = info["scheduled"]
|
|
121
|
-
|
|
122
|
-
cst = scheduled.astimezone(ZoneInfo("America/Chicago"))
|
|
123
|
-
time_in_cdt = cst.strftime("%I:%M %p")
|
|
124
|
-
|
|
125
|
-
self.server.sendBroadcast(
|
|
126
|
-
self.fmt(self.msg.broadcast_restart_at, time=time_in_cdt)
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
minute_callbacks = {
|
|
130
|
-
m: (lambda msg=self.fmt(self.msg.broadcast_minute[m]): self.server.sendBroadcast(msg))
|
|
131
|
-
for m in self.msg.broadcast_minute
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
second_callbacks = {
|
|
135
|
-
s: (lambda msg=self.fmt(self.msg.broadcast_second[s]): self.server.sendBroadcast(msg))
|
|
136
|
-
for s in self.msg.broadcast_second
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
runCountdownEvents(
|
|
140
|
-
target_time=scheduled,
|
|
141
|
-
minute_callbacks=minute_callbacks,
|
|
142
|
-
second_callbacks=second_callbacks,
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
def evaluate(self):
|
|
146
|
-
self.log.info(self.msg.log_start)
|
|
147
|
-
|
|
148
|
-
if not validateAll(self.panel, self.server):
|
|
149
|
-
self.log.error(self.msg.log_validation_fail)
|
|
150
|
-
self.shutdown()
|
|
151
|
-
|
|
152
|
-
self.server.refresh()
|
|
153
|
-
snap = Snapshot(self.server, 2, True)
|
|
154
|
-
|
|
155
|
-
pro = 0
|
|
156
|
-
anti = 0
|
|
157
|
-
restart_reasons = []
|
|
158
|
-
no_restart_reasons = []
|
|
159
|
-
|
|
160
|
-
# PRO-RESTART
|
|
161
|
-
if self.server.getSchedule(self.cfg.restart_soon_schedule_id)["is_active"]:
|
|
162
|
-
restart_reasons.append(self.msg.reason_restart_soon)
|
|
163
|
-
pro += self.cfg.weight_restart_soon
|
|
164
|
-
|
|
165
|
-
if snap.ram >= self.cfg.ram_threshold:
|
|
166
|
-
restart_reasons.append(
|
|
167
|
-
self.fmt(
|
|
168
|
-
self.msg.reason_ram,
|
|
169
|
-
ram=snap.ram,
|
|
170
|
-
threshold=self.cfg.ram_threshold,
|
|
171
|
-
)
|
|
172
|
-
)
|
|
173
|
-
pro += round(snap.ram, 0) - 5
|
|
174
|
-
|
|
175
|
-
if snap.cpu >= self.cfg.cpu_threshold:
|
|
176
|
-
restart_reasons.append(
|
|
177
|
-
self.fmt(
|
|
178
|
-
self.msg.reason_cpu,
|
|
179
|
-
cpu=snap.cpu,
|
|
180
|
-
threshold=self.cfg.cpu_threshold,
|
|
181
|
-
)
|
|
182
|
-
)
|
|
183
|
-
pro += self.cfg.weight_cpu
|
|
184
|
-
|
|
185
|
-
if snap.uptime // 3600 >= self.cfg.uptime_hours_threshold:
|
|
186
|
-
restart_reasons.append(
|
|
187
|
-
self.fmt(
|
|
188
|
-
self.msg.reason_uptime,
|
|
189
|
-
uptime=snap.uptime_formatted,
|
|
190
|
-
threshold=self.cfg.uptime_hours_threshold,
|
|
191
|
-
)
|
|
192
|
-
)
|
|
193
|
-
pro += self.cfg.weight_uptime
|
|
194
|
-
|
|
195
|
-
if snap.tps <= self.cfg.tps_threshold:
|
|
196
|
-
restart_reasons.append(
|
|
197
|
-
self.fmt(
|
|
198
|
-
self.msg.reason_tps,
|
|
199
|
-
tps=snap.tps,
|
|
200
|
-
threshold=self.cfg.tps_threshold,
|
|
201
|
-
)
|
|
202
|
-
)
|
|
203
|
-
pro += self.cfg.weight_tps
|
|
204
|
-
|
|
205
|
-
# ANTI-RESTART
|
|
206
|
-
if snap.uptime // 60 < 30:
|
|
207
|
-
no_restart_reasons.append(
|
|
208
|
-
self.fmt(
|
|
209
|
-
self.msg.reason_low_uptime,
|
|
210
|
-
uptime=snap.uptime_formatted,
|
|
211
|
-
)
|
|
212
|
-
)
|
|
213
|
-
anti += self.cfg.weight_low_uptime
|
|
214
|
-
|
|
215
|
-
if snap.players > 0:
|
|
216
|
-
verb = "are" if snap.players != 1 else "is"
|
|
217
|
-
plural = "players" if snap.players != 1 else "player"
|
|
218
|
-
no_restart_reasons.append(
|
|
219
|
-
self.fmt(
|
|
220
|
-
self.msg.reason_players,
|
|
221
|
-
verb=verb,
|
|
222
|
-
count=snap.players,
|
|
223
|
-
plural=plural,
|
|
224
|
-
)
|
|
225
|
-
)
|
|
226
|
-
anti += snap.players * self.cfg.weight_per_player
|
|
227
|
-
|
|
228
|
-
# LOGGING
|
|
229
|
-
for r in restart_reasons:
|
|
230
|
-
self.log.warn(f"- {r}")
|
|
231
|
-
for r in no_restart_reasons:
|
|
232
|
-
self.log.warn(f"- {r}")
|
|
233
|
-
|
|
234
|
-
self.log.warn(f"Pro-restart: {pro}")
|
|
235
|
-
self.log.warn(f"Anti-restart: {anti}")
|
|
236
|
-
|
|
237
|
-
gap = abs(pro - anti)
|
|
238
|
-
|
|
239
|
-
if pro == 0:
|
|
240
|
-
self.log.info(self.msg.log_no_restart)
|
|
241
|
-
return
|
|
242
|
-
|
|
243
|
-
if pro > anti and snap.players == 0:
|
|
244
|
-
self.log.info(self.msg.log_immediate_restart)
|
|
245
|
-
self.restart_and_wait()
|
|
246
|
-
return
|
|
247
|
-
|
|
248
|
-
self.log.info(self.msg.log_scheduled)
|
|
249
|
-
|
|
250
|
-
if gap <= 2:
|
|
251
|
-
self.log.warn(self.fmt(self.msg.log_gap_low, gap=gap))
|
|
252
|
-
self.schedule_restart(self.cfg.low_gap_minutes)
|
|
253
|
-
else:
|
|
254
|
-
self.log.warn(self.fmt(self.msg.log_gap_high, gap=gap))
|
|
255
|
-
self.schedule_restart(self.cfg.high_gap_minutes)
|
|
256
|
-
|
|
257
|
-
self.restart_and_wait()
|
|
258
|
-
|
|
259
|
-
def run(self):
|
|
260
|
-
clearTerminal()
|
|
261
|
-
while True:
|
|
262
|
-
self.evaluate()
|
|
263
|
-
time.sleep(60)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|