mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
855 lines
27 KiB
Python
855 lines
27 KiB
Python
#
|
|
# Copyright (c) 2024 YunoHost Contributors
|
|
#
|
|
# This file is part of YunoHost (see https://yunohost.org)
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
# License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
import re
|
|
import os
|
|
import time
|
|
import yaml
|
|
import subprocess
|
|
|
|
from glob import glob
|
|
from datetime import datetime
|
|
|
|
from moulinette import m18n
|
|
from yunohost.diagnosis import diagnosis_ignore, diagnosis_unignore
|
|
from yunohost.utils.error import YunohostError, YunohostValidationError
|
|
from moulinette.utils.process import check_output
|
|
from moulinette.utils.log import getActionLogger
|
|
from moulinette.utils.filesystem import (
|
|
read_file,
|
|
append_to_file,
|
|
write_to_file,
|
|
read_yaml,
|
|
write_to_yaml,
|
|
)
|
|
|
|
MOULINETTE_LOCK = "/var/run/moulinette_yunohost.lock"
|
|
|
|
SERVICES_CONF = "/etc/yunohost/services.yml"
|
|
SERVICES_CONF_BASE = "/usr/share/yunohost/conf/yunohost/services.yml"
|
|
|
|
logger = getActionLogger("yunohost.service")
|
|
|
|
|
|
def service_add(
|
|
name,
|
|
description=None,
|
|
log=None,
|
|
test_status=None,
|
|
test_conf=None,
|
|
needs_exposed_ports=None,
|
|
need_lock=False,
|
|
):
|
|
"""
|
|
Add a custom service
|
|
|
|
Keyword argument:
|
|
name -- Service name to add
|
|
description -- description of the service
|
|
log -- Absolute path to log file to display
|
|
test_status -- Specify a custom bash command to check the status of the service. N.B. : it only makes sense to specify this if the corresponding systemd service does not return the proper information.
|
|
test_conf -- Specify a custom bash command to check if the configuration of the service is valid or broken, similar to nginx -t.
|
|
needs_exposed_ports -- A list of ports that needs to be publicly exposed for the service to work as intended.
|
|
need_lock -- Use this option to prevent deadlocks if the service does invoke yunohost commands.
|
|
"""
|
|
services = _get_services()
|
|
|
|
services[name] = service = {}
|
|
|
|
if log is not None:
|
|
if not isinstance(log, list):
|
|
log = [log]
|
|
|
|
service["log"] = log
|
|
|
|
if not description:
|
|
# Try to get the description from systemd service
|
|
unit, _ = _get_service_information_from_systemd(name)
|
|
description = str(unit.get("Description", "")) if unit is not None else ""
|
|
# If the service does not yet exists or if the description is empty,
|
|
# systemd will anyway return foo.service as default value, so we wanna
|
|
# make sure there's actually something here.
|
|
if description == name + ".service":
|
|
description = ""
|
|
|
|
if description:
|
|
service["description"] = description
|
|
else:
|
|
logger.warning(
|
|
"/!\\ Packagers! You added a custom service without specifying a description. Please add a proper Description in the systemd configuration, or use --description to explain what the service does in a similar fashion to existing services."
|
|
)
|
|
|
|
if need_lock:
|
|
service["need_lock"] = True
|
|
|
|
if test_status:
|
|
service["test_status"] = test_status
|
|
else:
|
|
# Try to get the description from systemd service
|
|
_, systemd_info = _get_service_information_from_systemd(name)
|
|
type_ = systemd_info.get("Type") if systemd_info is not None else ""
|
|
if type_ == "oneshot":
|
|
logger.warning(
|
|
"/!\\ Packagers! Please provide a --test_status when adding oneshot-type services in Yunohost, such that it has a reliable way to check if the service is running or not."
|
|
)
|
|
|
|
if test_conf:
|
|
service["test_conf"] = test_conf
|
|
|
|
if needs_exposed_ports:
|
|
service["needs_exposed_ports"] = needs_exposed_ports
|
|
|
|
try:
|
|
_save_services(services)
|
|
except Exception as e:
|
|
logger.warning(e)
|
|
# we'll get a logger.warning with more details in _save_services
|
|
raise YunohostError("service_add_failed", service=name)
|
|
|
|
logger.success(m18n.n("service_added", service=name))
|
|
|
|
|
|
def service_remove(name):
|
|
"""
|
|
Remove a custom service
|
|
|
|
Keyword argument:
|
|
name -- Service name to remove
|
|
|
|
"""
|
|
services = _get_services()
|
|
|
|
if name not in services:
|
|
raise YunohostValidationError("service_unknown", service=name)
|
|
|
|
del services[name]
|
|
try:
|
|
_save_services(services)
|
|
except Exception:
|
|
# we'll get a logger.warning with more details in _save_services
|
|
raise YunohostError("service_remove_failed", service=name)
|
|
|
|
logger.success(m18n.n("service_removed", service=name))
|
|
|
|
|
|
def service_start(names):
|
|
"""
|
|
Start one or more services
|
|
|
|
Keyword argument:
|
|
names -- Services name to start
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
|
|
for name in names:
|
|
if _run_service_command("start", name):
|
|
logger.success(m18n.n("service_started", service=name))
|
|
else:
|
|
if service_status(name)["status"] != "running":
|
|
raise YunohostError(
|
|
"service_start_failed",
|
|
service=name,
|
|
logs=_get_journalctl_logs(name),
|
|
)
|
|
logger.debug(m18n.n("service_already_started", service=name))
|
|
|
|
|
|
def service_stop(names):
|
|
"""
|
|
Stop one or more services
|
|
|
|
Keyword argument:
|
|
name -- Services name to stop
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
for name in names:
|
|
if _run_service_command("stop", name):
|
|
logger.success(m18n.n("service_stopped", service=name))
|
|
else:
|
|
if service_status(name)["status"] != "inactive":
|
|
raise YunohostError(
|
|
"service_stop_failed", service=name, logs=_get_journalctl_logs(name)
|
|
)
|
|
logger.debug(m18n.n("service_already_stopped", service=name))
|
|
|
|
|
|
def service_reload(names):
|
|
"""
|
|
Reload one or more services
|
|
|
|
Keyword argument:
|
|
name -- Services name to reload
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
for name in names:
|
|
if _run_service_command("reload", name):
|
|
logger.success(m18n.n("service_reloaded", service=name))
|
|
else:
|
|
if service_status(name)["status"] != "inactive":
|
|
raise YunohostError(
|
|
"service_reload_failed",
|
|
service=name,
|
|
logs=_get_journalctl_logs(name),
|
|
)
|
|
|
|
|
|
def service_restart(names):
|
|
"""
|
|
Restart one or more services. If the services are not running yet, they will be started.
|
|
|
|
Keyword argument:
|
|
name -- Services name to restart
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
for name in names:
|
|
if _run_service_command("restart", name):
|
|
logger.success(m18n.n("service_restarted", service=name))
|
|
else:
|
|
if service_status(name)["status"] != "inactive":
|
|
raise YunohostError(
|
|
"service_restart_failed",
|
|
service=name,
|
|
logs=_get_journalctl_logs(name),
|
|
)
|
|
|
|
|
|
def service_reload_or_restart(names, test_conf=True):
|
|
"""
|
|
Reload one or more services if they support it. If not, restart them instead. If the services are not running yet, they will be started.
|
|
|
|
Keyword argument:
|
|
name -- Services name to reload or restart
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
|
|
services = _get_services()
|
|
|
|
for name in names:
|
|
logger.debug(f"Reloading service {name}")
|
|
|
|
test_conf_cmd = services.get(name, {}).get("test_conf")
|
|
if test_conf and test_conf_cmd:
|
|
p = subprocess.Popen(
|
|
test_conf_cmd,
|
|
shell=True,
|
|
executable="/bin/bash",
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
|
|
out, _ = p.communicate()
|
|
if p.returncode != 0:
|
|
errors = out.decode().strip().split("\n")
|
|
logger.error(
|
|
m18n.n(
|
|
"service_not_reloading_because_conf_broken",
|
|
name=name,
|
|
errors=errors,
|
|
)
|
|
)
|
|
continue
|
|
|
|
if _run_service_command("reload-or-restart", name):
|
|
logger.success(m18n.n("service_reloaded_or_restarted", service=name))
|
|
else:
|
|
if service_status(name)["status"] != "inactive":
|
|
raise YunohostError(
|
|
"service_reload_or_restart_failed",
|
|
service=name,
|
|
logs=_get_journalctl_logs(name),
|
|
)
|
|
|
|
|
|
def service_enable(names):
|
|
"""
|
|
Enable one or more services
|
|
|
|
Keyword argument:
|
|
names -- Services name to enable
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
for name in names:
|
|
if _run_service_command("enable", name):
|
|
diagnosis_unignore(["services", f"service={name}"])
|
|
logger.success(m18n.n("service_enabled", service=name))
|
|
else:
|
|
raise YunohostError(
|
|
"service_enable_failed", service=name, logs=_get_journalctl_logs(name)
|
|
)
|
|
|
|
|
|
def service_disable(names):
|
|
"""
|
|
Disable one or more services
|
|
|
|
Keyword argument:
|
|
names -- Services name to disable
|
|
|
|
"""
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
for name in names:
|
|
if _run_service_command("disable", name):
|
|
diagnosis_ignore(["services", f"service={name}"])
|
|
logger.success(m18n.n("service_disabled", service=name))
|
|
else:
|
|
raise YunohostError(
|
|
"service_disable_failed", service=name, logs=_get_journalctl_logs(name)
|
|
)
|
|
|
|
|
|
def service_status(names=[]):
|
|
"""
|
|
Show status information about one or more services (all by default)
|
|
|
|
Keyword argument:
|
|
names -- Services name to show
|
|
|
|
"""
|
|
services = _get_services()
|
|
|
|
# If function was called with a specific list of service
|
|
if names != []:
|
|
# If user wanna check the status of a single service
|
|
if isinstance(names, str):
|
|
names = [names]
|
|
|
|
# Validate service names requested
|
|
for name in names:
|
|
if name not in services.keys():
|
|
raise YunohostValidationError("service_unknown", service=name)
|
|
|
|
# Filter only requested servivces
|
|
services = {k: v for k, v in services.items() if k in names}
|
|
|
|
# Remove services that aren't "real" services
|
|
#
|
|
# the historical reason is because regenconf has been hacked into the
|
|
# service part of YunoHost will in some situation we need to regenconf
|
|
# for things that aren't services
|
|
# the hack was to add fake services...
|
|
services = {k: v for k, v in services.items() if v.get("status", "") is not None}
|
|
|
|
output = {
|
|
s: _get_and_format_service_status(s, infos) for s, infos in services.items()
|
|
}
|
|
|
|
if len(names) == 1:
|
|
return output[names[0]]
|
|
return output
|
|
|
|
|
|
def _get_service_information_from_systemd(service):
|
|
"this is the equivalent of 'systemctl status $service'"
|
|
import dbus
|
|
|
|
d = dbus.SystemBus()
|
|
|
|
systemd = d.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1")
|
|
manager = dbus.Interface(systemd, "org.freedesktop.systemd1.Manager")
|
|
|
|
# c.f. https://zignar.net/2014/09/08/getting-started-with-dbus-python-systemd/
|
|
# Very interface, much intuitive, wow
|
|
service_unit = manager.LoadUnit(service + ".service")
|
|
service_proxy = d.get_object("org.freedesktop.systemd1", str(service_unit))
|
|
properties_interface = dbus.Interface(
|
|
service_proxy, "org.freedesktop.DBus.Properties"
|
|
)
|
|
|
|
unit = properties_interface.GetAll("org.freedesktop.systemd1.Unit")
|
|
service = properties_interface.GetAll("org.freedesktop.systemd1.Service")
|
|
|
|
if unit.get("LoadState", "not-found") == "not-found":
|
|
# Service doesn't really exist
|
|
return (None, None)
|
|
else:
|
|
return (unit, service)
|
|
|
|
|
|
def _get_and_format_service_status(service, infos):
|
|
systemd_service = infos.get("actual_systemd_service", service)
|
|
raw_status, raw_service = _get_service_information_from_systemd(systemd_service)
|
|
|
|
if raw_status is None:
|
|
logger.error(
|
|
f"Failed to get status information via dbus for service {systemd_service}, systemctl didn't recognize this service ('NoSuchUnit')."
|
|
)
|
|
return {
|
|
"status": "unknown",
|
|
"start_on_boot": "unknown",
|
|
"last_state_change": "unknown",
|
|
"description": "Error: failed to get information for this service, it doesn't exists for systemd",
|
|
"configuration": "unknown",
|
|
}
|
|
|
|
# Try to get description directly from services.yml
|
|
description = infos.get("description")
|
|
|
|
# If no description was there, try to get it from the .json locales
|
|
if not description:
|
|
translation_key = f"service_description_{service}"
|
|
if m18n.key_exists(translation_key):
|
|
description = m18n.n(translation_key)
|
|
else:
|
|
description = str(raw_status.get("Description", ""))
|
|
|
|
output = {
|
|
"status": str(raw_status.get("SubState", "unknown")),
|
|
"start_on_boot": str(raw_status.get("UnitFileState", "unknown")),
|
|
"last_state_change": "unknown",
|
|
"description": description,
|
|
"configuration": "unknown",
|
|
}
|
|
|
|
# Fun stuff™ : to obtain the enabled/disabled status for sysv services,
|
|
# gotta do this ... cf code of /lib/systemd/systemd-sysv-install
|
|
if output["start_on_boot"] == "generated":
|
|
output["start_on_boot"] = (
|
|
"enabled" if glob("/etc/rc[S5].d/S??" + service) else "disabled"
|
|
)
|
|
elif os.path.exists(
|
|
f"/etc/systemd/system/multi-user.target.wants/{service}.service"
|
|
):
|
|
output["start_on_boot"] = "enabled"
|
|
|
|
if "StateChangeTimestamp" in raw_status:
|
|
output["last_state_change"] = datetime.utcfromtimestamp(
|
|
raw_status["StateChangeTimestamp"] / 1000000
|
|
)
|
|
|
|
# 'test_status' is an optional field to test the status of the service using a custom command
|
|
if "test_status" in infos:
|
|
p = subprocess.Popen(
|
|
infos["test_status"],
|
|
shell=True,
|
|
executable="/bin/bash",
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
|
|
p.communicate()
|
|
|
|
output["status"] = "running" if p.returncode == 0 else "failed"
|
|
elif (
|
|
raw_service.get("Type", "").lower() == "oneshot"
|
|
and output["status"] == "exited"
|
|
):
|
|
# These are services like yunohost-firewall, hotspot, vpnclient,
|
|
# ... they will be "exited" why doesn't provide any info about
|
|
# the real state of the service (unless they did provide a
|
|
# test_status, c.f. previous condition)
|
|
output["status"] = "unknown"
|
|
|
|
# 'test_status' is an optional field to test the status of the service using a custom command
|
|
if "test_conf" in infos:
|
|
p = subprocess.Popen(
|
|
infos["test_conf"],
|
|
shell=True,
|
|
executable="/bin/bash",
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
|
|
out, _ = p.communicate()
|
|
if p.returncode == 0:
|
|
output["configuration"] = "valid"
|
|
else:
|
|
out = out.decode()
|
|
output["configuration"] = "broken"
|
|
output["configuration-details"] = out.strip().split("\n")
|
|
|
|
return output
|
|
|
|
|
|
def service_log(name, number=50):
|
|
"""
|
|
Log every log files of a service
|
|
|
|
Keyword argument:
|
|
name -- Service name to log
|
|
number -- Number of lines to display
|
|
|
|
"""
|
|
services = _get_services()
|
|
number = int(number)
|
|
|
|
if name not in services.keys():
|
|
raise YunohostValidationError("service_unknown", service=name)
|
|
|
|
log_list = services[name].get("log", [])
|
|
|
|
if not isinstance(log_list, list):
|
|
log_list = [log_list]
|
|
|
|
# Legacy stuff related to --log_type where we'll typically have the service
|
|
# name in the log list but it's not an actual logfile. Nowadays journalctl
|
|
# is automatically fetch as well as regular log files.
|
|
if name in log_list:
|
|
log_list.remove(name)
|
|
|
|
result = {}
|
|
|
|
# First we always add the logs from journalctl / systemd
|
|
result["journalctl"] = _get_journalctl_logs(name, number).splitlines()
|
|
|
|
for log_path in log_list:
|
|
if not os.path.exists(log_path):
|
|
continue
|
|
|
|
# Make sure to resolve symlinks
|
|
log_path = os.path.realpath(log_path)
|
|
|
|
# log is a file, read it
|
|
if os.path.isfile(log_path):
|
|
result[log_path] = _tail(log_path, number)
|
|
continue
|
|
elif not os.path.isdir(log_path):
|
|
result[log_path] = []
|
|
continue
|
|
|
|
for log_file in os.listdir(log_path):
|
|
log_file_path = os.path.join(log_path, log_file)
|
|
# not a file : skip
|
|
if not os.path.isfile(log_file_path):
|
|
continue
|
|
|
|
if not log_file.endswith(".log"):
|
|
continue
|
|
|
|
result[log_file_path] = (
|
|
_tail(log_file_path, number) if os.path.exists(log_file_path) else []
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def _run_service_command(action, service):
|
|
"""
|
|
Run services management command (start, stop, enable, disable, restart, reload)
|
|
|
|
Keyword argument:
|
|
action -- Action to perform
|
|
service -- Service name
|
|
|
|
"""
|
|
services = _get_services()
|
|
if service not in services.keys():
|
|
raise YunohostValidationError("service_unknown", service=service)
|
|
|
|
possible_actions = [
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"reload",
|
|
"reload-or-restart",
|
|
"enable",
|
|
"disable",
|
|
]
|
|
if action not in possible_actions:
|
|
raise ValueError(
|
|
f"Unknown action '{action}', available actions are: {', '.join(possible_actions)}"
|
|
)
|
|
|
|
cmd = f"systemctl {action} {service}"
|
|
|
|
need_lock = services[service].get("need_lock", False) and action in [
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"reload",
|
|
"reload-or-restart",
|
|
]
|
|
|
|
if action in ["enable", "disable"]:
|
|
cmd += " --quiet"
|
|
|
|
try:
|
|
# Launch the command
|
|
logger.debug(f"Running '{cmd}'")
|
|
p = subprocess.Popen(cmd.split(), stderr=subprocess.STDOUT)
|
|
# If this command needs a lock (because the service uses yunohost
|
|
# commands inside), find the PID and add a lock for it
|
|
if need_lock:
|
|
PID = _give_lock(action, service, p)
|
|
# Wait for the command to complete
|
|
p.communicate()
|
|
|
|
if p.returncode != 0:
|
|
logger.warning(m18n.n("service_cmd_exec_failed", command=cmd))
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.warning(m18n.n("unexpected_error", error=str(e)))
|
|
return False
|
|
|
|
finally:
|
|
# Remove the lock if one was given
|
|
if need_lock and PID != 0:
|
|
_remove_lock(PID)
|
|
|
|
return True
|
|
|
|
|
|
def _give_lock(action, service, p):
|
|
# Depending of the action, systemctl calls the PID differently :/
|
|
if action == "start" or action == "restart":
|
|
systemctl_PID_name = "MainPID"
|
|
else:
|
|
systemctl_PID_name = "ControlPID"
|
|
|
|
cmd_get_son_PID = f"systemctl show {service} -p {systemctl_PID_name}"
|
|
son_PID = 0
|
|
# As long as we did not found the PID and that the command is still running
|
|
while son_PID == 0 and p.poll() is None:
|
|
# Call systemctl to get the PID
|
|
# Output of the command is e.g. ControlPID=1234
|
|
son_PID = check_output(cmd_get_son_PID).split("=")[1]
|
|
son_PID = int(son_PID)
|
|
time.sleep(1)
|
|
|
|
# If we found a PID
|
|
if son_PID != 0:
|
|
# Append the PID to the lock file
|
|
logger.debug(f"Giving a lock to PID {son_PID} for service {service} !")
|
|
append_to_file(MOULINETTE_LOCK, f"\n{son_PID}")
|
|
|
|
return son_PID
|
|
|
|
|
|
def _remove_lock(PID_to_remove):
|
|
# FIXME ironically not concurrency safe because it's not atomic...
|
|
|
|
PIDs = read_file(MOULINETTE_LOCK).split("\n")
|
|
PIDs_to_keep = [PID for PID in PIDs if int(PID) != PID_to_remove]
|
|
write_to_file(MOULINETTE_LOCK, "\n".join(PIDs_to_keep))
|
|
|
|
|
|
def _get_services():
|
|
"""
|
|
Get a dict of managed services with their parameters
|
|
|
|
"""
|
|
try:
|
|
services = read_yaml(SERVICES_CONF_BASE) or {}
|
|
|
|
# These are keys flagged 'null' in the base conf
|
|
legacy_keys_to_delete = [k for k, v in services.items() if v is None]
|
|
|
|
services.update(read_yaml(SERVICES_CONF) or {})
|
|
|
|
services = {
|
|
name: infos
|
|
for name, infos in services.items()
|
|
if name not in legacy_keys_to_delete
|
|
}
|
|
except Exception:
|
|
return {}
|
|
|
|
# Dirty hack to automatically find custom SSH port ...
|
|
ssh_port_line = re.findall(
|
|
r"\bPort *([0-9]{2,5})\b", read_file("/etc/ssh/sshd_config")
|
|
)
|
|
if len(ssh_port_line) == 1:
|
|
services["ssh"]["needs_exposed_ports"] = [int(ssh_port_line[0])]
|
|
|
|
# Dirty hack to check the status of ynh-vpnclient
|
|
if "ynh-vpnclient" in services:
|
|
if "log" not in services["ynh-vpnclient"]:
|
|
services["ynh-vpnclient"]["log"] = ["/var/log/ynh-vpnclient.log"]
|
|
|
|
services_with_package_condition = [
|
|
name
|
|
for name, infos in services.items()
|
|
if infos.get("ignore_if_package_is_not_installed")
|
|
]
|
|
for name in services_with_package_condition:
|
|
package = services[name]["ignore_if_package_is_not_installed"]
|
|
if (
|
|
check_output(
|
|
f"dpkg-query --show --showformat='${{db:Status-Status}}' '{package}' 2>/dev/null || true"
|
|
)
|
|
!= "installed"
|
|
):
|
|
del services[name]
|
|
|
|
php_fpm_versions = check_output(
|
|
r"dpkg --list | grep -P 'ii php\d.\d-fpm' | awk '{print $2}' | grep -o -P '\d.\d' || true",
|
|
cwd="/tmp",
|
|
)
|
|
php_fpm_versions = [v for v in php_fpm_versions.split("\n") if v.strip()]
|
|
|
|
for version in php_fpm_versions:
|
|
# Skip php 7.3 which is most likely dead after buster->bullseye migration
|
|
# because users get spooked
|
|
if version == "7.3":
|
|
continue
|
|
services[f"php{version}-fpm"] = {
|
|
"log": f"/var/log/php{version}-fpm.log",
|
|
"test_conf": f"php-fpm{version} --test", # ofc the service is phpx.y-fpm but the program is php-fpmx.y because why not ...
|
|
"category": "web",
|
|
}
|
|
|
|
# Ignore metronome entirely if XMPP was disabled on all domains
|
|
if "metronome" in services and not glob("/etc/metronome/conf.d/*.cfg.lua"):
|
|
del services["metronome"]
|
|
|
|
# Remove legacy /var/log/daemon.log and /var/log/syslog from log entries
|
|
# because they are too general. Instead, now the journalctl log is
|
|
# returned by default which is more relevant.
|
|
for infos in services.values():
|
|
if infos.get("log") in ["/var/log/syslog", "/var/log/daemon.log"]:
|
|
del infos["log"]
|
|
|
|
return services
|
|
|
|
|
|
def _save_services(services):
|
|
"""
|
|
Save managed services to files
|
|
|
|
Keyword argument:
|
|
services -- A dict of managed services with their parameters
|
|
|
|
"""
|
|
|
|
# Compute the diff with the base file
|
|
# such that /etc/yunohost/services.yml contains the minimal
|
|
# changes with respect to the base conf
|
|
|
|
conf_base = yaml.safe_load(open(SERVICES_CONF_BASE)) or {}
|
|
|
|
diff = {}
|
|
|
|
for service_name, service_infos in services.items():
|
|
# Ignore php-fpm services, they are to be added dynamically by the core,
|
|
# but not actually saved
|
|
if service_name.startswith("php") and service_name.endswith("-fpm"):
|
|
continue
|
|
|
|
service_conf_base = conf_base.get(service_name, {}) or {}
|
|
diff[service_name] = {}
|
|
|
|
for key, value in service_infos.items():
|
|
if service_conf_base.get(key) != value:
|
|
diff[service_name][key] = value
|
|
|
|
diff = {
|
|
name: infos for name, infos in diff.items() if infos or name not in conf_base
|
|
}
|
|
|
|
write_to_yaml(SERVICES_CONF, diff)
|
|
|
|
|
|
def _tail(file, n):
|
|
"""
|
|
Reads a n lines from f with an offset of offset lines. The return
|
|
value is a tuple in the form ``(lines, has_more)`` where `has_more` is
|
|
an indicator that is `True` if there are more lines in the file.
|
|
|
|
This function works even with splitted logs (gz compression, log rotate...)
|
|
"""
|
|
avg_line_length = 74
|
|
to_read = n
|
|
|
|
try:
|
|
if file.endswith(".gz"):
|
|
import gzip
|
|
|
|
f = gzip.open(file)
|
|
lines = f.read().splitlines()
|
|
else:
|
|
f = open(file, errors="replace")
|
|
pos = 1
|
|
lines = []
|
|
while len(lines) < to_read and pos > 0:
|
|
try:
|
|
f.seek(-(avg_line_length * to_read), 2)
|
|
except IOError:
|
|
# woops. apparently file is smaller than what we want
|
|
# to step back, go to the beginning instead
|
|
f.seek(0)
|
|
|
|
pos = f.tell()
|
|
lines = f.read().splitlines()
|
|
|
|
if len(lines) >= to_read:
|
|
return lines[-to_read:]
|
|
|
|
avg_line_length *= 1.3
|
|
f.close()
|
|
|
|
except IOError as e:
|
|
logger.warning("Error while tailing file '%s': %s", file, e, exc_info=1)
|
|
return []
|
|
|
|
if len(lines) < to_read:
|
|
previous_log_file = _find_previous_log_file(file)
|
|
if previous_log_file is not None:
|
|
lines = _tail(previous_log_file, to_read - len(lines)) + lines
|
|
|
|
return lines
|
|
|
|
|
|
def _find_previous_log_file(file):
|
|
"""
|
|
Find the previous log file
|
|
"""
|
|
splitext = os.path.splitext(file)
|
|
if splitext[1] == ".gz":
|
|
file = splitext[0]
|
|
splitext = os.path.splitext(file)
|
|
ext = splitext[1]
|
|
i = re.findall(r"\.(\d+)", ext)
|
|
i = int(i[0]) + 1 if len(i) > 0 else 1
|
|
|
|
previous_file = file if i == 1 else splitext[0]
|
|
previous_file = previous_file + f".{i}"
|
|
if os.path.exists(previous_file):
|
|
return previous_file
|
|
|
|
previous_file = previous_file + ".gz"
|
|
if os.path.exists(previous_file):
|
|
return previous_file
|
|
|
|
return None
|
|
|
|
|
|
def _get_journalctl_logs(service, number="all"):
|
|
services = _get_services()
|
|
systemd_service = services.get(service, {}).get("actual_systemd_service", service)
|
|
try:
|
|
return check_output(
|
|
f"journalctl --no-hostname --no-pager -u {systemd_service} -n{number}"
|
|
)
|
|
except Exception:
|
|
import traceback
|
|
|
|
trace_ = traceback.format_exc()
|
|
return f"error while get services logs from journalctl:\n{trace_}"
|