Skip to content

Commit 810f067

Browse files
committed
Add Minecraft scheduled restart and unified logs
1 parent 827c6d3 commit 810f067

6 files changed

Lines changed: 396 additions & 26 deletions

File tree

panels/minecraft/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
- 게임 컨테이너와 전체 서버 데이터를 초기화한 뒤 엔진 설치부터 다시 시작
1313
- 서버 실행 중 설정/파일 쓰기 잠금
1414
- `/data` 전체 서버 디렉터리 탐색기
15+
- 한국표준시 기준 매일 예약 재시작 및 최근 실행 결과 확인
1516
- CPU, 메모리, 디스크, 네트워크 리소스 5초 주기 모니터링
1617
- Docker 이미지 Pull 진행 로그, 웹패널 업데이트 진행률 및 VM 재부팅 후 자동 복구
1718

1819
게임 데이터는 호스트의 `/opt/techtim/minecraft/data/server`에 유지되고 게임 컨테이너의 `/data`에 마운트됩니다.
1920

21+
예약 재시작 설정은 호스트의 `/opt/techtim/minecraft/data/restart-schedule.json`에 저장됩니다. 예약 시각에 서버가 실행 중일 때만 월드 저장 후 게임 컨테이너를 재시작하며, 서버가 꺼져 있거나 백업·복원이 진행 중이면 해당 회차를 건너뜁니다.
22+
2023
## Local Docker Build
2124

2225
```bash

panels/minecraft/app/main.py

Lines changed: 244 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
PANEL_VERSION = os.getenv("PANEL_VERSION", "1.0.0")
3030
STATIC_ASSET_VERSION = hashlib.sha256(
3131
(APP_DIR / "static" / "app.css").read_bytes()
32+
+ (APP_DIR / "static" / "panel-overrides.css").read_bytes()
3233
+ (APP_DIR / "static" / "app.js").read_bytes()
3334
).hexdigest()[:12]
3435
MINECRAFT_VERSION_MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
@@ -57,13 +58,20 @@
5758
CONTROL_LOG_FILE = DATA_DIR / "server-control.log"
5859
BACKUP_CONFIG_FILE = DATA_DIR / "backup-config.json"
5960
BACKUP_STATUS_FILE = DATA_DIR / "backup-status.json"
61+
RESTART_SCHEDULE_FILE = DATA_DIR / "restart-schedule.json"
6062
PANEL_UPDATE_STATUS_FILE = DATA_DIR / "panel-update-status.json"
6163
SESSION_COOKIE = "techtim_session"
6264
INSTALL_LOCK = threading.Lock()
6365
INSTALL_ACTIVE = False
6466
SERVER_STDIN_LOCK = threading.Lock()
65-
BACKUP_LOCK = threading.Lock()
67+
MAINTENANCE_LOCK = threading.Lock()
68+
BACKUP_LOCK = MAINTENANCE_LOCK
6669
BACKUP_ACTIVE = False
70+
RESTART_SCHEDULE_LOCK = threading.Lock()
71+
RESTART_OPERATION_LOCK = MAINTENANCE_LOCK
72+
RESTART_OPERATION_ACTIVE = False
73+
RESTART_SCHEDULER_STOP = threading.Event()
74+
RESTART_SCHEDULER_THREAD: threading.Thread | None = None
6775
PANEL_UPDATE_LOCK = threading.Lock()
6876
PANEL_UPDATE_ACTIVE = False
6977
PANEL_UPDATE_CHECK_LOCK = threading.Lock()
@@ -137,6 +145,11 @@ class BackupConfigRequest(BaseModel):
137145
retention_count: int = Field(default=7, ge=1, le=50)
138146

139147

148+
class RestartScheduleRequest(BaseModel):
149+
enabled: bool = False
150+
restart_time: str = Field(default="04:00", pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
151+
152+
140153
class ConfigRequest(BaseModel):
141154
Type: str = "PAPER"
142155
Version: str = "LATEST"
@@ -542,7 +555,7 @@ def prune_backup_archives(retention_count: int) -> None:
542555
def claim_backup_operation() -> bool:
543556
global BACKUP_ACTIVE
544557
with BACKUP_LOCK:
545-
if BACKUP_ACTIVE:
558+
if BACKUP_ACTIVE or RESTART_OPERATION_ACTIVE:
546559
return False
547560
BACKUP_ACTIVE = True
548561
return True
@@ -694,6 +707,160 @@ def backup_scheduler() -> None:
694707
write_backup_status("failed", f"자동 백업 스케줄 확인 실패: {clean_log(str(error))}")
695708

696709

710+
def default_restart_schedule() -> dict[str, Any]:
711+
return {
712+
"enabled": False,
713+
"restart_time": "04:00",
714+
"last_run_date": "",
715+
"last_run_at": "",
716+
"last_result": "not_run",
717+
"last_message": "예약 재시작 실행 기록이 없습니다.",
718+
}
719+
720+
721+
def normalize_restart_schedule(raw: Any) -> dict[str, Any]:
722+
defaults = default_restart_schedule()
723+
stored = raw if isinstance(raw, dict) else {}
724+
restart_time = str(stored.get("restart_time") or defaults["restart_time"]).strip()
725+
if not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", restart_time):
726+
restart_time = defaults["restart_time"]
727+
return {
728+
"enabled": bool(stored.get("enabled", defaults["enabled"])),
729+
"restart_time": restart_time,
730+
"last_run_date": str(stored.get("last_run_date") or ""),
731+
"last_run_at": str(stored.get("last_run_at") or ""),
732+
"last_result": str(stored.get("last_result") or defaults["last_result"]),
733+
"last_message": str(stored.get("last_message") or defaults["last_message"]),
734+
}
735+
736+
737+
def read_restart_schedule() -> dict[str, Any]:
738+
with RESTART_SCHEDULE_LOCK:
739+
return normalize_restart_schedule(read_json(RESTART_SCHEDULE_FILE, {}))
740+
741+
742+
def write_restart_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
743+
normalized = normalize_restart_schedule(schedule)
744+
ensure_dirs()
745+
with RESTART_SCHEDULE_LOCK:
746+
write_json(RESTART_SCHEDULE_FILE, normalized)
747+
return normalized
748+
749+
750+
def restart_schedule_response(schedule: dict[str, Any] | None = None) -> dict[str, Any]:
751+
current = schedule or read_restart_schedule()
752+
next_run_at = ""
753+
if current["enabled"]:
754+
hour, minute = (int(part) for part in current["restart_time"].split(":"))
755+
now = datetime.now(KST)
756+
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
757+
if target <= now or current.get("last_run_date") == now.date().isoformat():
758+
target += timedelta(days=1)
759+
next_run_at = target.isoformat(timespec="minutes")
760+
return {
761+
**current,
762+
"timezone": "Asia/Seoul",
763+
"next_run_at": next_run_at,
764+
"active": restart_operation_active(),
765+
}
766+
767+
768+
def restart_operation_active() -> bool:
769+
with RESTART_OPERATION_LOCK:
770+
return RESTART_OPERATION_ACTIVE
771+
772+
773+
def claim_restart_operation() -> bool:
774+
global RESTART_OPERATION_ACTIVE
775+
with RESTART_OPERATION_LOCK:
776+
if RESTART_OPERATION_ACTIVE or BACKUP_ACTIVE:
777+
return False
778+
RESTART_OPERATION_ACTIVE = True
779+
return True
780+
781+
782+
def release_restart_operation() -> None:
783+
global RESTART_OPERATION_ACTIVE
784+
with RESTART_OPERATION_LOCK:
785+
RESTART_OPERATION_ACTIVE = False
786+
787+
788+
def update_restart_schedule_result(result: str, message: str) -> None:
789+
with RESTART_SCHEDULE_LOCK:
790+
schedule = normalize_restart_schedule(read_json(RESTART_SCHEDULE_FILE, {}))
791+
schedule["last_result"] = result
792+
schedule["last_message"] = message
793+
write_json(RESTART_SCHEDULE_FILE, schedule)
794+
795+
796+
def claim_due_restart(now: datetime) -> dict[str, Any] | None:
797+
with RESTART_SCHEDULE_LOCK:
798+
schedule = normalize_restart_schedule(read_json(RESTART_SCHEDULE_FILE, {}))
799+
today = now.date().isoformat()
800+
if (
801+
not schedule["enabled"]
802+
or now.strftime("%H:%M") != schedule["restart_time"]
803+
or schedule["last_run_date"] == today
804+
):
805+
return None
806+
schedule["last_run_date"] = today
807+
schedule["last_run_at"] = now.isoformat(timespec="seconds")
808+
schedule["last_result"] = "running"
809+
schedule["last_message"] = "예약된 Minecraft 서버 재시작을 처리하고 있습니다."
810+
write_json(RESTART_SCHEDULE_FILE, schedule)
811+
return schedule
812+
813+
814+
def run_scheduled_restart_if_due() -> None:
815+
schedule = claim_due_restart(datetime.now(KST))
816+
if not schedule:
817+
return
818+
if not claim_restart_operation():
819+
message = "백업 또는 복원 작업이 진행 중이어서 이번 예약 재시작을 건너뛰었습니다."
820+
append_log(CONTROL_LOG_FILE, message)
821+
update_restart_schedule_result("skipped", message)
822+
return
823+
824+
try:
825+
container = get_container()
826+
if not container:
827+
message = "예약 시각에 게임 서버 컨테이너가 없어 재시작을 건너뛰었습니다."
828+
append_log(CONTROL_LOG_FILE, message)
829+
update_restart_schedule_result("skipped", message)
830+
return
831+
container.reload()
832+
if container.status != "running":
833+
message = "예약 시각에 Minecraft 서버가 실행 중이 아니어서 재시작을 건너뛰었습니다."
834+
append_log(CONTROL_LOG_FILE, message)
835+
update_restart_schedule_result("skipped", message)
836+
return
837+
838+
append_log(
839+
CONTROL_LOG_FILE,
840+
f"매일 {schedule['restart_time']} 한국표준시 예약에 따라 Minecraft 게임 컨테이너를 재시작합니다.",
841+
)
842+
try:
843+
send_backup_console_command(container, "save-all flush")
844+
time.sleep(2)
845+
except (docker.errors.DockerException, OSError, RuntimeError) as error:
846+
append_log(CONTROL_LOG_FILE, f"예약 재시작 전 월드 저장 확인 필요: {clean_log(str(error))}")
847+
container.restart(timeout=60)
848+
message = "예약된 Minecraft 게임 컨테이너 재시작이 완료되었습니다."
849+
append_log(CONTROL_LOG_FILE, message)
850+
update_restart_schedule_result("success", message)
851+
except Exception as error:
852+
message = f"예약 재시작 중 오류가 발생했습니다: {clean_log(str(error))}"
853+
append_log(CONTROL_LOG_FILE, message)
854+
update_restart_schedule_result("error", message)
855+
finally:
856+
release_restart_operation()
857+
858+
859+
def restart_scheduler_loop() -> None:
860+
while not RESTART_SCHEDULER_STOP.wait(5):
861+
run_scheduled_restart_if_due()
862+
863+
697864
def public_server_ip() -> str:
698865
configured = str(os.getenv("PUBLIC_IP") or "").strip()
699866
if configured:
@@ -1361,9 +1528,25 @@ def login_html(change: bool = False) -> str:
13611528

13621529

13631530
@app.on_event("startup")
1364-
def start_backup_scheduler() -> None:
1531+
def start_background_schedulers() -> None:
1532+
global RESTART_SCHEDULER_THREAD
13651533
ensure_dirs()
13661534
threading.Thread(target=backup_scheduler, daemon=True, name="minecraft-backup-scheduler").start()
1535+
if not RESTART_SCHEDULER_THREAD or not RESTART_SCHEDULER_THREAD.is_alive():
1536+
RESTART_SCHEDULER_STOP.clear()
1537+
RESTART_SCHEDULER_THREAD = threading.Thread(
1538+
target=restart_scheduler_loop,
1539+
daemon=True,
1540+
name="minecraft-restart-scheduler",
1541+
)
1542+
RESTART_SCHEDULER_THREAD.start()
1543+
1544+
1545+
@app.on_event("shutdown")
1546+
def stop_background_schedulers() -> None:
1547+
RESTART_SCHEDULER_STOP.set()
1548+
if RESTART_SCHEDULER_THREAD and RESTART_SCHEDULER_THREAD.is_alive():
1549+
RESTART_SCHEDULER_THREAD.join(timeout=6)
13671550

13681551

13691552
@app.get("/login", response_class=HTMLResponse)
@@ -1596,6 +1779,8 @@ def delete_server_icon(request: Request):
15961779
@app.post("/api/server/start")
15971780
def start_server(payload: StartServerRequest, request: Request):
15981781
require_auth(request)
1782+
if restart_operation_active():
1783+
raise HTTPException(status_code=409, detail="예약 재시작이 완료된 후 서버를 시작해주세요.")
15991784
if backup_operation_active():
16001785
raise HTTPException(status_code=409, detail="백업 또는 복원 작업 중에는 서버를 시작할 수 없습니다.")
16011786
if not payload.eula_accepted:
@@ -1657,6 +1842,8 @@ def start_server(payload: StartServerRequest, request: Request):
16571842
@app.post("/api/server/stop")
16581843
def stop_server(request: Request):
16591844
require_auth(request)
1845+
if restart_operation_active():
1846+
raise HTTPException(status_code=409, detail="예약 재시작이 완료된 후 서버를 중지해주세요.")
16601847
if backup_operation_active():
16611848
raise HTTPException(status_code=409, detail="백업 또는 복원 작업이 끝난 후 서버를 중지해주세요.")
16621849
container = get_container()
@@ -1684,6 +1871,8 @@ def stop_server(request: Request):
16841871
@app.post("/api/server/restart")
16851872
def restart_server(request: Request):
16861873
require_auth(request)
1874+
if restart_operation_active():
1875+
raise HTTPException(status_code=409, detail="예약 재시작이 이미 진행 중입니다.")
16871876
if backup_operation_active():
16881877
raise HTTPException(status_code=409, detail="백업 또는 복원 작업이 끝난 후 서버를 재시작해주세요.")
16891878
container = get_container()
@@ -1797,9 +1986,7 @@ def server_resources(request: Request):
17971986
return resources
17981987

17991988

1800-
@app.get("/api/server/log")
1801-
def server_log(request: Request):
1802-
require_auth(request)
1989+
def read_server_log() -> str:
18031990
container = get_container()
18041991
logs = ""
18051992
if container:
@@ -1810,7 +1997,21 @@ def server_log(request: Request):
18101997
control = clean_log(CONTROL_LOG_FILE.read_text(encoding="utf-8")) if CONTROL_LOG_FILE.exists() else ""
18111998
if control:
18121999
logs = f"[패널 제어 로그]\n{control.rstrip()}\n\n{logs.lstrip()}".strip()
1813-
return {"log": logs}
2000+
return logs
2001+
2002+
2003+
@app.get("/api/server/log")
2004+
def server_log(request: Request):
2005+
require_auth(request)
2006+
return {"log": read_server_log()}
2007+
2008+
2009+
@app.get("/api/log")
2010+
def combined_log(request: Request):
2011+
require_auth(request)
2012+
install = clean_log(INSTALL_LOG_FILE.read_text(encoding="utf-8")).strip() if INSTALL_LOG_FILE.exists() else ""
2013+
server = read_server_log().strip()
2014+
return {"log": "\n\n".join(part for part in (install, server) if part)}
18142015

18152016

18162017
@app.post("/api/server/command")
@@ -2025,6 +2226,42 @@ def delete_backup(filename: str, request: Request):
20252226
return {"status": "deleted", "message": "백업 파일을 삭제했습니다."}
20262227

20272228

2229+
@app.get("/api/restart-schedule")
2230+
def get_restart_schedule(request: Request):
2231+
require_auth(request)
2232+
return {
2233+
"status": "ok",
2234+
"schedule": restart_schedule_response(),
2235+
}
2236+
2237+
2238+
@app.post("/api/restart-schedule")
2239+
def save_restart_schedule(payload: RestartScheduleRequest, request: Request):
2240+
require_auth(request)
2241+
existing = read_restart_schedule()
2242+
changed = (
2243+
existing["enabled"] != payload.enabled
2244+
or existing["restart_time"] != payload.restart_time
2245+
)
2246+
schedule = {
2247+
**existing,
2248+
"enabled": payload.enabled,
2249+
"restart_time": payload.restart_time,
2250+
}
2251+
if changed:
2252+
schedule["last_result"] = "not_run"
2253+
schedule["last_message"] = "새 예약이 저장되었습니다."
2254+
schedule["last_run_date"] = ""
2255+
schedule["last_run_at"] = ""
2256+
saved = write_restart_schedule(schedule)
2257+
state = "활성화" if saved["enabled"] else "비활성화"
2258+
return {
2259+
"status": "ok",
2260+
"message": f"게임 서버 예약 재시작이 {state}되었습니다.",
2261+
"schedule": restart_schedule_response(saved),
2262+
}
2263+
2264+
20282265
@app.get("/api/files")
20292266
def list_files(request: Request, path: str = ""):
20302267
require_auth(request)

0 commit comments

Comments
 (0)