serverwatcher 3.0__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-3.0/src/serverwatcher.egg-info → serverwatcher-3.1}/PKG-INFO +1 -1
- {serverwatcher-3.0 → serverwatcher-3.1}/pyproject.toml +1 -1
- 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/watcher.py +280 -0
- {serverwatcher-3.0 → serverwatcher-3.1/src/serverwatcher.egg-info}/PKG-INFO +1 -1
- {serverwatcher-3.0 → serverwatcher-3.1}/src/serverwatcher.egg-info/SOURCES.txt +4 -3
- serverwatcher-3.0/src/serverwatcher/config.py +0 -39
- serverwatcher-3.0/src/serverwatcher/messages.py +0 -43
- serverwatcher-3.0/src/serverwatcher/watcher.py +0 -334
- {serverwatcher-3.0 → serverwatcher-3.1}/LICENSE +0 -0
- {serverwatcher-3.0 → serverwatcher-3.1}/README.md +0 -0
- {serverwatcher-3.0 → serverwatcher-3.1}/setup.cfg +0 -0
- {serverwatcher-3.0 → serverwatcher-3.1}/src/serverwatcher/__init__.py +0 -0
- {serverwatcher-3.0 → serverwatcher-3.1}/src/serverwatcher/schema.py +0 -0
- {serverwatcher-3.0 → serverwatcher-3.1}/src/serverwatcher.egg-info/dependency_links.txt +0 -0
- {serverwatcher-3.0 → serverwatcher-3.1}/src/serverwatcher.egg-info/requires.txt +0 -0
- {serverwatcher-3.0 → serverwatcher-3.1}/src/serverwatcher.egg-info/top_level.txt +0 -0
|
@@ -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,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,12 +2,13 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/serverwatcher/__init__.py
|
|
5
|
-
src/serverwatcher/config.py
|
|
6
|
-
src/serverwatcher/messages.py
|
|
7
5
|
src/serverwatcher/schema.py
|
|
8
6
|
src/serverwatcher/watcher.py
|
|
9
7
|
src/serverwatcher.egg-info/PKG-INFO
|
|
10
8
|
src/serverwatcher.egg-info/SOURCES.txt
|
|
11
9
|
src/serverwatcher.egg-info/dependency_links.txt
|
|
12
10
|
src/serverwatcher.egg-info/requires.txt
|
|
13
|
-
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,39 +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
|
|
34
|
-
|
|
35
|
-
# logger / misc
|
|
36
|
-
logger_name_template: str = "ServerWatcher-{server_name}"
|
|
37
|
-
log_path: str = "/home/container/logs/"
|
|
38
|
-
console_backspaces: int = 8
|
|
39
|
-
timezone: str = "America/Chicago"
|
|
@@ -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,334 +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
|
-
write_default_yaml,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
from .config import WatcherConfig
|
|
20
|
-
from .messages import WatcherMessages
|
|
21
|
-
from .schema import validate_config_schema, validate_messages_schema
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
CONFIG_PATH = "config.yaml"
|
|
25
|
-
MESSAGES_PATH = "messages.yaml"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
DEFAULT_CONFIG = {
|
|
29
|
-
"panel": {
|
|
30
|
-
"name": "My Panel",
|
|
31
|
-
"url": "https://example.com",
|
|
32
|
-
"api_key": "CHANGE_ME",
|
|
33
|
-
},
|
|
34
|
-
"origin": {
|
|
35
|
-
"server_id": "CHANGE_ME",
|
|
36
|
-
},
|
|
37
|
-
"server": {
|
|
38
|
-
"name": "My SMP",
|
|
39
|
-
"server_id": "CHANGE_ME",
|
|
40
|
-
"domain": "example.com",
|
|
41
|
-
"port": 25565,
|
|
42
|
-
"rcon_port": 25575,
|
|
43
|
-
"rcon_password": "password",
|
|
44
|
-
"tps_command": "ticks",
|
|
45
|
-
},
|
|
46
|
-
"watcher": {},
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
DEFAULT_MESSAGES = {
|
|
51
|
-
"prefix": "<aqua>[Server Watcher]",
|
|
52
|
-
"broadcast_restart_at": "{prefix} The server will restart at {time} CDT.",
|
|
53
|
-
"broadcast_minute": {
|
|
54
|
-
120: "{prefix} Restart in 2 hours!",
|
|
55
|
-
60: "{prefix} Restart in 1 hour!",
|
|
56
|
-
45: "{prefix} Restart in 45 minutes!",
|
|
57
|
-
30: "{prefix} Restart in 30 minutes!",
|
|
58
|
-
15: "{prefix} Restart in 15 minutes!",
|
|
59
|
-
5: "{prefix} Restart in 5 minutes!",
|
|
60
|
-
1: "{prefix} Restart in 1 minute!",
|
|
61
|
-
},
|
|
62
|
-
"broadcast_second": {
|
|
63
|
-
s: "{prefix} Restart in " + str(s) + " seconds!"
|
|
64
|
-
for s in range(10, 0, -1)
|
|
65
|
-
},
|
|
66
|
-
"log_start": "ServerWatcher is running!",
|
|
67
|
-
"log_validation_fail": "Validation FAILED. Make sure you set up config.yaml.",
|
|
68
|
-
"log_validation_ok": "All validation checks succeeded.",
|
|
69
|
-
"log_immediate_restart": "Restarting immediately.",
|
|
70
|
-
"log_no_restart": "The server does not need to restart.",
|
|
71
|
-
"log_scheduled": "Restart needed, but anti-restart factors outweigh it.",
|
|
72
|
-
"log_gap_low": "Gap {gap}. Scheduling restart in 2 hours.",
|
|
73
|
-
"log_gap_high": "Gap {gap}. Scheduling restart in 1 hour.",
|
|
74
|
-
"reason_restart_soon": "The server is set to restart soon",
|
|
75
|
-
"reason_ram": "RAM usage ({ram}) is higher than {threshold} GB",
|
|
76
|
-
"reason_cpu": "CPU usage ({cpu}) is higher than {threshold}%",
|
|
77
|
-
"reason_uptime": "Uptime {uptime} exceeds {threshold}h",
|
|
78
|
-
"reason_tps": "TPS {tps} is lower than {threshold}",
|
|
79
|
-
"reason_low_uptime": "Uptime {uptime} is shorter than 30m",
|
|
80
|
-
"reason_players": "There {verb} {count} {plural} online",
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
class ServerWatcher:
|
|
85
|
-
@staticmethod
|
|
86
|
-
def generate_default_files(
|
|
87
|
-
config_path: str = CONFIG_PATH,
|
|
88
|
-
messages_path: str = MESSAGES_PATH,
|
|
89
|
-
overwrite: bool = False,
|
|
90
|
-
):
|
|
91
|
-
write_default_yaml(config_path, DEFAULT_CONFIG, overwrite=overwrite)
|
|
92
|
-
write_default_yaml(messages_path, DEFAULT_MESSAGES, overwrite=overwrite)
|
|
93
|
-
|
|
94
|
-
def __init__(
|
|
95
|
-
self,
|
|
96
|
-
config_path: str = CONFIG_PATH,
|
|
97
|
-
messages_path: str = MESSAGES_PATH,
|
|
98
|
-
):
|
|
99
|
-
# ensure files exist
|
|
100
|
-
ensure_yaml(config_path, DEFAULT_CONFIG)
|
|
101
|
-
ensure_yaml(messages_path, DEFAULT_MESSAGES)
|
|
102
|
-
|
|
103
|
-
# load raw YAML
|
|
104
|
-
raw_config = load_yaml(config_path)
|
|
105
|
-
raw_messages = load_yaml(messages_path)
|
|
106
|
-
|
|
107
|
-
# validate schemas
|
|
108
|
-
config_errors = validate_config_schema(raw_config)
|
|
109
|
-
messages_errors = validate_messages_schema(raw_messages)
|
|
110
|
-
all_errors = config_errors + messages_errors
|
|
111
|
-
if all_errors:
|
|
112
|
-
msg = "Configuration errors:\n" + "\n".join(f"- {e}" for e in all_errors)
|
|
113
|
-
raise ValueError(msg)
|
|
114
|
-
|
|
115
|
-
# map watcher + messages into dataclasses
|
|
116
|
-
self.cfg: WatcherConfig = map_to_dataclass(
|
|
117
|
-
raw_config.get("watcher", {}), WatcherConfig
|
|
118
|
-
)
|
|
119
|
-
self.msg: WatcherMessages = map_to_dataclass(
|
|
120
|
-
raw_messages, WatcherMessages
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
# panel / origin / server wiring from YAML
|
|
124
|
-
p = raw_config["panel"]
|
|
125
|
-
self.panel = Panel(
|
|
126
|
-
name=p["name"],
|
|
127
|
-
url=p["url"],
|
|
128
|
-
api_key=p["api_key"],
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
o = raw_config["origin"]
|
|
132
|
-
self.origin = GenericServer(
|
|
133
|
-
name="Origin",
|
|
134
|
-
panel=self.panel,
|
|
135
|
-
server_id=o["server_id"],
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
s = raw_config["server"]
|
|
139
|
-
self.server = MinecraftServer(
|
|
140
|
-
name=s["name"],
|
|
141
|
-
panel=self.panel,
|
|
142
|
-
server_id=s["server_id"],
|
|
143
|
-
server_domain=s["domain"],
|
|
144
|
-
server_port=s["port"],
|
|
145
|
-
rcon_port=s["rcon_port"],
|
|
146
|
-
rcon_password=s["rcon_password"],
|
|
147
|
-
tpsCommand=s["tps_command"],
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
# logger now fully configurable
|
|
151
|
-
logger_name = self.cfg.logger_name_template.format(server_name=s["name"])
|
|
152
|
-
self.log = HungerLogger(
|
|
153
|
-
name=logger_name,
|
|
154
|
-
server=self.server,
|
|
155
|
-
log_path=self.cfg.log_path,
|
|
156
|
-
console_backspaces=self.cfg.console_backspaces,
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
# timezone
|
|
160
|
-
self.tz = ZoneInfo(self.cfg.timezone)
|
|
161
|
-
|
|
162
|
-
def fmt(self, template: str, **kwargs):
|
|
163
|
-
return template.format(prefix=self.msg.prefix, **kwargs)
|
|
164
|
-
|
|
165
|
-
def shutdown(self):
|
|
166
|
-
self.log.info("Shutting down ServerWatcher.")
|
|
167
|
-
raise SystemExit
|
|
168
|
-
|
|
169
|
-
def restart_and_wait(self):
|
|
170
|
-
self.origin.disableSchedule(self.cfg.origin_disable_schedule_id)
|
|
171
|
-
self.server.restart()
|
|
172
|
-
self.log.info("Restart action sent. Waiting...")
|
|
173
|
-
time.sleep(self.cfg.restart_wait_seconds)
|
|
174
|
-
|
|
175
|
-
self.log.warn("Checking server status...")
|
|
176
|
-
alive = waitForOnline(
|
|
177
|
-
self.server,
|
|
178
|
-
timeout=self.cfg.restart_online_timeout,
|
|
179
|
-
interval=self.cfg.restart_online_interval,
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
if alive:
|
|
183
|
-
self.log.info("Server is back online!")
|
|
184
|
-
self.server.sendBroadcast(f"{self.msg.prefix}<green>Restart successful!")
|
|
185
|
-
self.origin.enableSchedule(self.cfg.origin_disable_schedule_id)
|
|
186
|
-
else:
|
|
187
|
-
self.log.error("Server failed to restart!")
|
|
188
|
-
|
|
189
|
-
def schedule_restart(self, minutes):
|
|
190
|
-
info = snapSchedule(minimumMinutes=minutes)
|
|
191
|
-
scheduled = info["scheduled"]
|
|
192
|
-
|
|
193
|
-
local_time = scheduled.astimezone(self.tz)
|
|
194
|
-
time_str = local_time.strftime("%I:%M %p")
|
|
195
|
-
|
|
196
|
-
self.server.sendBroadcast(
|
|
197
|
-
self.fmt(self.msg.broadcast_restart_at, time=time_str)
|
|
198
|
-
)
|
|
199
|
-
|
|
200
|
-
minute_callbacks = {
|
|
201
|
-
m: (lambda msg=self.fmt(self.msg.broadcast_minute[m]): self.server.sendBroadcast(msg))
|
|
202
|
-
for m in self.msg.broadcast_minute
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
second_callbacks = {
|
|
206
|
-
s: (lambda msg=self.fmt(self.msg.broadcast_second[s]): self.server.sendBroadcast(msg))
|
|
207
|
-
for s in self.msg.broadcast_second
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
runCountdownEvents(
|
|
211
|
-
target_time=scheduled,
|
|
212
|
-
minute_callbacks=minute_callbacks,
|
|
213
|
-
second_callbacks=second_callbacks,
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
def evaluate(self):
|
|
217
|
-
self.log.info(self.msg.log_start)
|
|
218
|
-
|
|
219
|
-
if not validateAll(self.panel, self.server):
|
|
220
|
-
self.log.error(self.msg.log_validation_fail)
|
|
221
|
-
self.shutdown()
|
|
222
|
-
|
|
223
|
-
self.server.refresh()
|
|
224
|
-
snap = Snapshot(self.server, 2, True)
|
|
225
|
-
|
|
226
|
-
pro = 0
|
|
227
|
-
anti = 0
|
|
228
|
-
restart_reasons = []
|
|
229
|
-
no_restart_reasons = []
|
|
230
|
-
|
|
231
|
-
# PRO-RESTART
|
|
232
|
-
if self.server.getSchedule(self.cfg.restart_soon_schedule_id)["is_active"]:
|
|
233
|
-
restart_reasons.append(self.msg.reason_restart_soon)
|
|
234
|
-
pro += self.cfg.weight_restart_soon
|
|
235
|
-
|
|
236
|
-
if snap.ram >= self.cfg.ram_threshold:
|
|
237
|
-
restart_reasons.append(
|
|
238
|
-
self.fmt(
|
|
239
|
-
self.msg.reason_ram,
|
|
240
|
-
ram=snap.ram,
|
|
241
|
-
threshold=self.cfg.ram_threshold,
|
|
242
|
-
)
|
|
243
|
-
)
|
|
244
|
-
pro += round(snap.ram, 0) - 5
|
|
245
|
-
|
|
246
|
-
if snap.cpu >= self.cfg.cpu_threshold:
|
|
247
|
-
restart_reasons.append(
|
|
248
|
-
self.fmt(
|
|
249
|
-
self.msg.reason_cpu,
|
|
250
|
-
cpu=snap.cpu,
|
|
251
|
-
threshold=self.cfg.cpu_threshold,
|
|
252
|
-
)
|
|
253
|
-
)
|
|
254
|
-
pro += self.cfg.weight_cpu
|
|
255
|
-
|
|
256
|
-
if snap.uptime // 3600 >= self.cfg.uptime_hours_threshold:
|
|
257
|
-
restart_reasons.append(
|
|
258
|
-
self.fmt(
|
|
259
|
-
self.msg.reason_uptime,
|
|
260
|
-
uptime=snap.uptime_formatted,
|
|
261
|
-
threshold=self.cfg.uptime_hours_threshold,
|
|
262
|
-
)
|
|
263
|
-
)
|
|
264
|
-
pro += self.cfg.weight_uptime
|
|
265
|
-
|
|
266
|
-
if snap.tps <= self.cfg.tps_threshold:
|
|
267
|
-
restart_reasons.append(
|
|
268
|
-
self.fmt(
|
|
269
|
-
self.msg.reason_tps,
|
|
270
|
-
tps=snap.tps,
|
|
271
|
-
threshold=self.cfg.tps_threshold,
|
|
272
|
-
)
|
|
273
|
-
)
|
|
274
|
-
pro += self.cfg.weight_tps
|
|
275
|
-
|
|
276
|
-
# ANTI-RESTART
|
|
277
|
-
if snap.uptime // 60 < 30:
|
|
278
|
-
no_restart_reasons.append(
|
|
279
|
-
self.fmt(
|
|
280
|
-
self.msg.reason_low_uptime,
|
|
281
|
-
uptime=snap.uptime_formatted,
|
|
282
|
-
)
|
|
283
|
-
)
|
|
284
|
-
anti += self.cfg.weight_low_uptime
|
|
285
|
-
|
|
286
|
-
if snap.players > 0:
|
|
287
|
-
verb = "are" if snap.players != 1 else "is"
|
|
288
|
-
plural = "players" if snap.players != 1 else "player"
|
|
289
|
-
no_restart_reasons.append(
|
|
290
|
-
self.fmt(
|
|
291
|
-
self.msg.reason_players,
|
|
292
|
-
verb=verb,
|
|
293
|
-
count=snap.players,
|
|
294
|
-
plural=plural,
|
|
295
|
-
)
|
|
296
|
-
)
|
|
297
|
-
anti += snap.players * self.cfg.weight_per_player
|
|
298
|
-
|
|
299
|
-
# LOGGING
|
|
300
|
-
for r in restart_reasons:
|
|
301
|
-
self.log.warn(f"- {r}")
|
|
302
|
-
for r in no_restart_reasons:
|
|
303
|
-
self.log.warn(f"- {r}")
|
|
304
|
-
|
|
305
|
-
self.log.warn(f"Pro-restart: {pro}")
|
|
306
|
-
self.log.warn(f"Anti-restart: {anti}")
|
|
307
|
-
|
|
308
|
-
gap = abs(pro - anti)
|
|
309
|
-
|
|
310
|
-
if pro == 0:
|
|
311
|
-
self.log.info(self.msg.log_no_restart)
|
|
312
|
-
return
|
|
313
|
-
|
|
314
|
-
if pro > anti and snap.players == 0:
|
|
315
|
-
self.log.info(self.msg.log_immediate_restart)
|
|
316
|
-
self.restart_and_wait()
|
|
317
|
-
return
|
|
318
|
-
|
|
319
|
-
self.log.info(self.msg.log_scheduled)
|
|
320
|
-
|
|
321
|
-
if gap <= 2:
|
|
322
|
-
self.log.warn(self.fmt(self.msg.log_gap_low, gap=gap))
|
|
323
|
-
self.schedule_restart(self.cfg.low_gap_minutes)
|
|
324
|
-
else:
|
|
325
|
-
self.log.warn(self.fmt(self.msg.log_gap_high, gap=gap))
|
|
326
|
-
self.schedule_restart(self.cfg.high_gap_minutes)
|
|
327
|
-
|
|
328
|
-
self.restart_and_wait()
|
|
329
|
-
|
|
330
|
-
def run(self):
|
|
331
|
-
clearTerminal()
|
|
332
|
-
while True:
|
|
333
|
-
self.evaluate()
|
|
334
|
-
time.sleep(60)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|