Files
Juan Denis 6296887bad Remove internal plan citations from app code
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
2026-10-10 15:50:45 -04:00

161 lines
5.4 KiB
Python

"""Bound the log files ServerKit writes or causes.
Four files grew without limit:
- the panel's own ``access.log`` / ``error.log`` / ``alerts.log`` /
``security_alerts.log`` (appended forever — no logrotate file existed);
- one ``update-<ts>.log`` per update, never pruned;
- per-app build logs under ``builds/<app>/``, never pruned;
- managed containers' json-file logs (no size cap anywhere).
``converge()`` runs at panel start on a Linux host; the prunes also run on the
6-hour retention tick. Everything is best-effort: a host without logrotate or
Docker simply skips that part.
"""
import glob
import json
import logging
import os
import posixpath
logger = logging.getLogger(__name__)
LOGROTATE_PATH = '/etc/logrotate.d/serverkit'
DOCKER_DAEMON_JSON = '/etc/docker/daemon.json'
ROTATED_LOGS = ('access.log', 'error.log', 'alerts.log', 'security_alerts.log')
KEEP_UPDATE_LOGS = 10
KEEP_BUILD_LOGS_PER_APP = 10
DOCKER_LOG_MAX_FILE = '3'
def logrotate_config(log_dir):
# Always a Linux path: this file is only ever read by logrotate.
paths = ' '.join(posixpath.join(log_dir, name) for name in ROTATED_LOGS)
# copytruncate: gunicorn holds access/error.log open and is never told to
# reopen them, so moving the file away would keep it writing to the old one.
return (
'# Managed by ServerKit - rewritten at panel start.\n'
f'{paths} {{\n'
' weekly\n'
' maxsize 50M\n'
' rotate 4\n'
' missingok\n'
' notifempty\n'
' compress\n'
' delaycompress\n'
' copytruncate\n'
'}\n'
)
def ensure_logrotate(log_dir, path=LOGROTATE_PATH):
"""Write the logrotate file when logrotate is installed. Returns True when
the file was written or changed."""
if not os.path.isdir(os.path.dirname(path)):
return False
content = logrotate_config(log_dir)
try:
with open(path, encoding='utf-8') as f:
if f.read() == content:
return False
except OSError:
pass
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
return True
def ensure_docker_log_opts(max_size, path=DOCKER_DAEMON_JSON):
"""Give Docker a default json-file size cap, merged into an existing
daemon.json. Never restarts Docker (that would restart every app), so it
applies to containers created after Docker's next restart.
Left alone: no Docker on the host, a daemon.json that does not parse (we
will not rewrite a file we cannot read), a non-json-file log driver, or a
max-size the operator already chose. Returns True when the file changed.
"""
if not os.path.isdir(os.path.dirname(path)):
return False
config = {}
if os.path.exists(path):
try:
with open(path, encoding='utf-8') as f:
config = json.load(f)
except (OSError, ValueError):
logger.warning('Not setting Docker log limits: %s does not parse', path)
return False
if not isinstance(config, dict):
return False
if config.get('log-driver', 'json-file') != 'json-file':
return False
opts = config.get('log-opts') or {}
if not isinstance(opts, dict) or 'max-size' in opts:
return False
opts = {**opts, 'max-size': str(max_size)}
opts.setdefault('max-file', DOCKER_LOG_MAX_FILE)
config['log-opts'] = opts
tmp = path + '.serverkit-tmp'
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2)
f.write('\n')
os.replace(tmp, path)
return True
def _remove_all_but_newest(paths, keep):
doomed = sorted(paths, key=lambda p: os.path.getmtime(p), reverse=True)[keep:]
removed = 0
for path in doomed:
try:
os.remove(path)
removed += 1
except OSError:
pass
return removed
def prune_update_logs(log_dir, keep=KEEP_UPDATE_LOGS):
return _remove_all_but_newest(glob.glob(os.path.join(log_dir, 'update-*.log')), keep)
def prune_build_logs(build_log_dir, keep=KEEP_BUILD_LOGS_PER_APP):
removed = 0
for app_dir in glob.glob(os.path.join(build_log_dir, '*')):
if os.path.isdir(app_dir):
removed += _remove_all_but_newest(
glob.glob(os.path.join(app_dir, 'build-*.json')), keep)
return removed
def prune_files():
"""The retention-tick half: old update and build logs."""
from app import paths
return {
'update_logs': prune_update_logs(paths.SERVERKIT_LOG_DIR),
'build_logs': prune_build_logs(paths.BUILD_LOG_DIR),
}
def converge():
"""Panel-start half: logrotate + Docker defaults, then the file prunes.
Linux hosts only; every step is independent and best-effort."""
if os.name == 'nt':
return {}
from app import paths
from app.services import storage_profile_service
from app.services.settings_service import SettingsService
result = {}
steps = (
('logrotate', lambda: ensure_logrotate(paths.SERVERKIT_LOG_DIR)),
('docker_log_opts', lambda: ensure_docker_log_opts(
SettingsService.get('storage.docker_log_max_size')
or storage_profile_service.default_for('storage.docker_log_max_size'))),
('files', prune_files),
)
for name, step in steps:
try:
result[name] = step()
except Exception as e:
logger.warning('Log limits: %s skipped: %s', name, e)
return result