mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Brutally move regenconf stuff to a new regenconf.py file
This commit is contained in:
parent
42b41aa934
commit
739bf8e559
2 changed files with 546 additions and 475 deletions
546
src/yunohost/regenconf.py
Normal file
546
src/yunohost/regenconf.py
Normal file
|
@ -0,0 +1,546 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
""" License
|
||||||
|
|
||||||
|
Copyright (C) 2019 YunoHost
|
||||||
|
|
||||||
|
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 os
|
||||||
|
import yaml
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from difflib import unified_diff
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from moulinette import m18n
|
||||||
|
from moulinette.utils import log, filesystem
|
||||||
|
|
||||||
|
from yunohost.utils.error import YunohostError
|
||||||
|
from yunohost.log import is_unit_operation
|
||||||
|
from yunohost.hook import hook_callback, hook_list
|
||||||
|
|
||||||
|
BASE_CONF_PATH = '/home/yunohost.conf'
|
||||||
|
BACKUP_CONF_DIR = os.path.join(BASE_CONF_PATH, 'backup')
|
||||||
|
PENDING_CONF_DIR = os.path.join(BASE_CONF_PATH, 'pending')
|
||||||
|
|
||||||
|
logger = log.getActionLogger('yunohost.regenconf')
|
||||||
|
|
||||||
|
|
||||||
|
@is_unit_operation([('names', 'service')])
|
||||||
|
def regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run=False,
|
||||||
|
list_pending=False):
|
||||||
|
"""
|
||||||
|
Regenerate the configuration file(s) for a service
|
||||||
|
|
||||||
|
Keyword argument:
|
||||||
|
names -- Services name to regenerate configuration of
|
||||||
|
with_diff -- Show differences in case of configuration changes
|
||||||
|
force -- Override all manual modifications in configuration files
|
||||||
|
dry_run -- Show what would have been regenerated
|
||||||
|
list_pending -- List pending configuration files and exit
|
||||||
|
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Return the list of pending conf
|
||||||
|
if list_pending:
|
||||||
|
pending_conf = _get_pending_conf(names)
|
||||||
|
|
||||||
|
if not with_diff:
|
||||||
|
return pending_conf
|
||||||
|
|
||||||
|
for service, conf_files in pending_conf.items():
|
||||||
|
for system_path, pending_path in conf_files.items():
|
||||||
|
|
||||||
|
pending_conf[service][system_path] = {
|
||||||
|
'pending_conf': pending_path,
|
||||||
|
'diff': _get_files_diff(
|
||||||
|
system_path, pending_path, True),
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending_conf
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
operation_logger.related_to = [('service', x) for x in names]
|
||||||
|
if not names:
|
||||||
|
operation_logger.name_parameter_override = 'all'
|
||||||
|
elif len(names) != 1:
|
||||||
|
operation_logger.name_parameter_override = str(len(operation_logger.related_to)) + '_services'
|
||||||
|
operation_logger.start()
|
||||||
|
|
||||||
|
# Clean pending conf directory
|
||||||
|
if os.path.isdir(PENDING_CONF_DIR):
|
||||||
|
if not names:
|
||||||
|
shutil.rmtree(PENDING_CONF_DIR, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
for name in names:
|
||||||
|
shutil.rmtree(os.path.join(PENDING_CONF_DIR, name),
|
||||||
|
ignore_errors=True)
|
||||||
|
else:
|
||||||
|
filesystem.mkdir(PENDING_CONF_DIR, 0o755, True)
|
||||||
|
|
||||||
|
# Format common hooks arguments
|
||||||
|
common_args = [1 if force else 0, 1 if dry_run else 0]
|
||||||
|
|
||||||
|
# Execute hooks for pre-regen
|
||||||
|
pre_args = ['pre', ] + common_args
|
||||||
|
|
||||||
|
def _pre_call(name, priority, path, args):
|
||||||
|
# create the pending conf directory for the service
|
||||||
|
service_pending_path = os.path.join(PENDING_CONF_DIR, name)
|
||||||
|
filesystem.mkdir(service_pending_path, 0o755, True, uid='root')
|
||||||
|
|
||||||
|
# return the arguments to pass to the script
|
||||||
|
return pre_args + [service_pending_path, ]
|
||||||
|
|
||||||
|
# Don't regen SSH if not specifically specified
|
||||||
|
if not names:
|
||||||
|
names = hook_list('conf_regen', list_by='name',
|
||||||
|
show_info=False)['hooks']
|
||||||
|
names.remove('ssh')
|
||||||
|
|
||||||
|
pre_result = hook_callback('conf_regen', names, pre_callback=_pre_call)
|
||||||
|
|
||||||
|
# Update the services name
|
||||||
|
names = pre_result['succeed'].keys()
|
||||||
|
|
||||||
|
if not names:
|
||||||
|
raise YunohostError('service_regenconf_failed',
|
||||||
|
services=', '.join(pre_result['failed']))
|
||||||
|
|
||||||
|
# Set the processing method
|
||||||
|
_regen = _process_regen_conf if not dry_run else lambda *a, **k: True
|
||||||
|
|
||||||
|
operation_logger.related_to = []
|
||||||
|
|
||||||
|
# Iterate over services and process pending conf
|
||||||
|
for service, conf_files in _get_pending_conf(names).items():
|
||||||
|
if not dry_run:
|
||||||
|
operation_logger.related_to.append(('service', service))
|
||||||
|
|
||||||
|
logger.debug(m18n.n(
|
||||||
|
'service_regenconf_pending_applying' if not dry_run else
|
||||||
|
'service_regenconf_dry_pending_applying',
|
||||||
|
service=service))
|
||||||
|
|
||||||
|
conf_hashes = _get_conf_hashes(service)
|
||||||
|
succeed_regen = {}
|
||||||
|
failed_regen = {}
|
||||||
|
|
||||||
|
for system_path, pending_path in conf_files.items():
|
||||||
|
logger.debug("processing pending conf '%s' to system conf '%s'",
|
||||||
|
pending_path, system_path)
|
||||||
|
conf_status = None
|
||||||
|
regenerated = False
|
||||||
|
|
||||||
|
# Get the diff between files
|
||||||
|
conf_diff = _get_files_diff(
|
||||||
|
system_path, pending_path, True) if with_diff else None
|
||||||
|
|
||||||
|
# Check if the conf must be removed
|
||||||
|
to_remove = True if os.path.getsize(pending_path) == 0 else False
|
||||||
|
|
||||||
|
# Retrieve and calculate hashes
|
||||||
|
system_hash = _calculate_hash(system_path)
|
||||||
|
saved_hash = conf_hashes.get(system_path, None)
|
||||||
|
new_hash = None if to_remove else _calculate_hash(pending_path)
|
||||||
|
|
||||||
|
# -> system conf does not exists
|
||||||
|
if not system_hash:
|
||||||
|
if to_remove:
|
||||||
|
logger.debug("> system conf is already removed")
|
||||||
|
os.remove(pending_path)
|
||||||
|
continue
|
||||||
|
if not saved_hash or force:
|
||||||
|
if force:
|
||||||
|
logger.debug("> system conf has been manually removed")
|
||||||
|
conf_status = 'force-created'
|
||||||
|
else:
|
||||||
|
logger.debug("> system conf does not exist yet")
|
||||||
|
conf_status = 'created'
|
||||||
|
regenerated = _regen(
|
||||||
|
system_path, pending_path, save=False)
|
||||||
|
else:
|
||||||
|
logger.info(m18n.n(
|
||||||
|
'service_conf_file_manually_removed',
|
||||||
|
conf=system_path))
|
||||||
|
conf_status = 'removed'
|
||||||
|
|
||||||
|
# -> system conf is not managed yet
|
||||||
|
elif not saved_hash:
|
||||||
|
logger.debug("> system conf is not managed yet")
|
||||||
|
if system_hash == new_hash:
|
||||||
|
logger.debug("> no changes to system conf has been made")
|
||||||
|
conf_status = 'managed'
|
||||||
|
regenerated = True
|
||||||
|
elif not to_remove:
|
||||||
|
# If the conf exist but is not managed yet, and is not to be removed,
|
||||||
|
# we assume that it is safe to regen it, since the file is backuped
|
||||||
|
# anyway (by default in _regen), as long as we warn the user
|
||||||
|
# appropriately.
|
||||||
|
logger.info(m18n.n('service_conf_now_managed_by_yunohost',
|
||||||
|
conf=system_path))
|
||||||
|
regenerated = _regen(system_path, pending_path)
|
||||||
|
conf_status = 'new'
|
||||||
|
elif force:
|
||||||
|
regenerated = _regen(system_path)
|
||||||
|
conf_status = 'force-removed'
|
||||||
|
else:
|
||||||
|
logger.info(m18n.n('service_conf_file_kept_back',
|
||||||
|
conf=system_path, service=service))
|
||||||
|
conf_status = 'unmanaged'
|
||||||
|
|
||||||
|
# -> system conf has not been manually modified
|
||||||
|
elif system_hash == saved_hash:
|
||||||
|
if to_remove:
|
||||||
|
regenerated = _regen(system_path)
|
||||||
|
conf_status = 'removed'
|
||||||
|
elif system_hash != new_hash:
|
||||||
|
regenerated = _regen(system_path, pending_path)
|
||||||
|
conf_status = 'updated'
|
||||||
|
else:
|
||||||
|
logger.debug("> system conf is already up-to-date")
|
||||||
|
os.remove(pending_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.debug("> system conf has been manually modified")
|
||||||
|
if system_hash == new_hash:
|
||||||
|
logger.debug("> new conf is as current system conf")
|
||||||
|
conf_status = 'managed'
|
||||||
|
regenerated = True
|
||||||
|
elif force:
|
||||||
|
regenerated = _regen(system_path, pending_path)
|
||||||
|
conf_status = 'force-updated'
|
||||||
|
else:
|
||||||
|
logger.warning(m18n.n(
|
||||||
|
'service_conf_file_manually_modified',
|
||||||
|
conf=system_path))
|
||||||
|
conf_status = 'modified'
|
||||||
|
|
||||||
|
# Store the result
|
||||||
|
conf_result = {'status': conf_status}
|
||||||
|
if conf_diff is not None:
|
||||||
|
conf_result['diff'] = conf_diff
|
||||||
|
if regenerated:
|
||||||
|
succeed_regen[system_path] = conf_result
|
||||||
|
conf_hashes[system_path] = new_hash
|
||||||
|
if os.path.isfile(pending_path):
|
||||||
|
os.remove(pending_path)
|
||||||
|
else:
|
||||||
|
failed_regen[system_path] = conf_result
|
||||||
|
|
||||||
|
# Check for service conf changes
|
||||||
|
if not succeed_regen and not failed_regen:
|
||||||
|
logger.debug(m18n.n('service_conf_up_to_date', service=service))
|
||||||
|
continue
|
||||||
|
elif not failed_regen:
|
||||||
|
logger.success(m18n.n(
|
||||||
|
'service_conf_updated' if not dry_run else
|
||||||
|
'service_conf_would_be_updated',
|
||||||
|
service=service))
|
||||||
|
|
||||||
|
if succeed_regen and not dry_run:
|
||||||
|
_update_conf_hashes(service, conf_hashes)
|
||||||
|
|
||||||
|
# Append the service results
|
||||||
|
result[service] = {
|
||||||
|
'applied': succeed_regen,
|
||||||
|
'pending': failed_regen
|
||||||
|
}
|
||||||
|
|
||||||
|
# Return in case of dry run
|
||||||
|
if dry_run:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Execute hooks for post-regen
|
||||||
|
post_args = ['post', ] + common_args
|
||||||
|
|
||||||
|
def _pre_call(name, priority, path, args):
|
||||||
|
# append coma-separated applied changes for the service
|
||||||
|
if name in result and result[name]['applied']:
|
||||||
|
regen_conf_files = ','.join(result[name]['applied'].keys())
|
||||||
|
else:
|
||||||
|
regen_conf_files = ''
|
||||||
|
return post_args + [regen_conf_files, ]
|
||||||
|
|
||||||
|
hook_callback('conf_regen', names, pre_callback=_pre_call)
|
||||||
|
|
||||||
|
operation_logger.success()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_services():
|
||||||
|
"""
|
||||||
|
Get a dict of managed services with their parameters
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open('/etc/yunohost/services.yml', 'r') as f:
|
||||||
|
services = yaml.load(f)
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
else:
|
||||||
|
# some services are marked as None to remove them from YunoHost
|
||||||
|
# filter this
|
||||||
|
for key, value in services.items():
|
||||||
|
if value is None:
|
||||||
|
del services[key]
|
||||||
|
|
||||||
|
return services
|
||||||
|
|
||||||
|
|
||||||
|
def _save_services(services):
|
||||||
|
"""
|
||||||
|
Save managed services to files
|
||||||
|
|
||||||
|
Keyword argument:
|
||||||
|
services -- A dict of managed services with their parameters
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open('/etc/yunohost/services.yml', 'w') as f:
|
||||||
|
yaml.safe_dump(services, f, default_flow_style=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Error while saving services, exception: %s', e, exc_info=1)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _get_files_diff(orig_file, new_file, as_string=False, skip_header=True):
|
||||||
|
"""Compare two files and return the differences
|
||||||
|
|
||||||
|
Read and compare two files. The differences are returned either as a delta
|
||||||
|
in unified diff format or a formatted string if as_string is True. The
|
||||||
|
header can also be removed if skip_header is True.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if os.path.exists(orig_file):
|
||||||
|
with open(orig_file, 'r') as orig_file:
|
||||||
|
orig_file = orig_file.readlines()
|
||||||
|
else:
|
||||||
|
orig_file = []
|
||||||
|
|
||||||
|
if os.path.exists(new_file):
|
||||||
|
with open(new_file, 'r') as new_file:
|
||||||
|
new_file = new_file.readlines()
|
||||||
|
else:
|
||||||
|
new_file = []
|
||||||
|
|
||||||
|
# Compare files and format output
|
||||||
|
diff = unified_diff(orig_file, new_file)
|
||||||
|
|
||||||
|
if skip_header:
|
||||||
|
try:
|
||||||
|
next(diff)
|
||||||
|
next(diff)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if as_string:
|
||||||
|
return ''.join(diff).rstrip()
|
||||||
|
|
||||||
|
return diff
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_hash(path):
|
||||||
|
"""Calculate the MD5 hash of a file"""
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
hasher = hashlib.md5()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
hasher.update(f.read())
|
||||||
|
return hasher.hexdigest()
|
||||||
|
|
||||||
|
except IOError as e:
|
||||||
|
logger.warning("Error while calculating file '%s' hash: %s", path, e, exc_info=1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pending_conf(services=[]):
|
||||||
|
"""Get pending configuration for service(s)
|
||||||
|
|
||||||
|
Iterate over the pending configuration directory for given service(s) - or
|
||||||
|
all if empty - and look for files inside. Each file is considered as a
|
||||||
|
pending configuration file and therefore must be in the same directory
|
||||||
|
tree than the system file that it replaces.
|
||||||
|
The result is returned as a dict of services with pending configuration as
|
||||||
|
key and a dict of `system_conf_path` => `pending_conf_path` as value.
|
||||||
|
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
if not os.path.isdir(PENDING_CONF_DIR):
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not services:
|
||||||
|
services = os.listdir(PENDING_CONF_DIR)
|
||||||
|
|
||||||
|
for name in services:
|
||||||
|
service_pending_path = os.path.join(PENDING_CONF_DIR, name)
|
||||||
|
|
||||||
|
if not os.path.isdir(service_pending_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
path_index = len(service_pending_path)
|
||||||
|
service_conf = {}
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(service_pending_path):
|
||||||
|
for filename in files:
|
||||||
|
pending_path = os.path.join(root, filename)
|
||||||
|
service_conf[pending_path[path_index:]] = pending_path
|
||||||
|
|
||||||
|
if service_conf:
|
||||||
|
result[name] = service_conf
|
||||||
|
else:
|
||||||
|
# remove empty directory
|
||||||
|
shutil.rmtree(service_pending_path, ignore_errors=True)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conf_hashes(service):
|
||||||
|
"""Get the registered conf hashes for a service"""
|
||||||
|
|
||||||
|
services = _get_services()
|
||||||
|
|
||||||
|
if service not in services:
|
||||||
|
logger.debug("Service %s is not in services.yml yet.", service)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
elif services[service] is None or 'conffiles' not in services[service]:
|
||||||
|
logger.debug("No configuration files for service %s.", service)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return services[service]['conffiles']
|
||||||
|
|
||||||
|
|
||||||
|
def _update_conf_hashes(service, hashes):
|
||||||
|
"""Update the registered conf hashes for a service"""
|
||||||
|
logger.debug("updating conf hashes for '%s' with: %s",
|
||||||
|
service, hashes)
|
||||||
|
services = _get_services()
|
||||||
|
service_conf = services.get(service, {})
|
||||||
|
|
||||||
|
# Handle the case where services[service] is set to null in the yaml
|
||||||
|
if service_conf is None:
|
||||||
|
service_conf = {}
|
||||||
|
|
||||||
|
service_conf['conffiles'] = hashes
|
||||||
|
services[service] = service_conf
|
||||||
|
_save_services(services)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_regen_conf(system_conf, new_conf=None, save=True):
|
||||||
|
"""Regenerate a given system configuration file
|
||||||
|
|
||||||
|
Replace a given system configuration file by a new one or delete it if
|
||||||
|
new_conf is None. A backup of the file - keeping its directory tree - will
|
||||||
|
be done in the backup conf directory before any operation if save is True.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if save:
|
||||||
|
backup_path = os.path.join(BACKUP_CONF_DIR, '{0}-{1}'.format(
|
||||||
|
system_conf.lstrip('/'), datetime.utcnow().strftime("%Y%m%d.%H%M%S")))
|
||||||
|
backup_dir = os.path.dirname(backup_path)
|
||||||
|
|
||||||
|
if not os.path.isdir(backup_dir):
|
||||||
|
filesystem.mkdir(backup_dir, 0o755, True)
|
||||||
|
|
||||||
|
shutil.copy2(system_conf, backup_path)
|
||||||
|
logger.debug(m18n.n('service_conf_file_backed_up',
|
||||||
|
conf=system_conf, backup=backup_path))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not new_conf:
|
||||||
|
os.remove(system_conf)
|
||||||
|
logger.debug(m18n.n('service_conf_file_removed',
|
||||||
|
conf=system_conf))
|
||||||
|
else:
|
||||||
|
system_dir = os.path.dirname(system_conf)
|
||||||
|
|
||||||
|
if not os.path.isdir(system_dir):
|
||||||
|
filesystem.mkdir(system_dir, 0o755, True)
|
||||||
|
|
||||||
|
shutil.copyfile(new_conf, system_conf)
|
||||||
|
logger.debug(m18n.n('service_conf_file_updated',
|
||||||
|
conf=system_conf))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Exception while trying to regenerate conf '%s': %s", system_conf, e, exc_info=1)
|
||||||
|
if not new_conf and os.path.exists(system_conf):
|
||||||
|
logger.warning(m18n.n('service_conf_file_remove_failed',
|
||||||
|
conf=system_conf),
|
||||||
|
exc_info=1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
elif new_conf:
|
||||||
|
try:
|
||||||
|
# From documentation:
|
||||||
|
# Raise an exception if an os.stat() call on either pathname fails.
|
||||||
|
# (os.stats returns a series of information from a file like type, size...)
|
||||||
|
copy_succeed = os.path.samefile(system_conf, new_conf)
|
||||||
|
except:
|
||||||
|
copy_succeed = False
|
||||||
|
finally:
|
||||||
|
if not copy_succeed:
|
||||||
|
logger.warning(m18n.n('service_conf_file_copy_failed',
|
||||||
|
conf=system_conf, new=new_conf),
|
||||||
|
exc_info=1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def manually_modified_files():
|
||||||
|
|
||||||
|
# We do this to have --quiet, i.e. don't throw a whole bunch of logs
|
||||||
|
# just to fetch this...
|
||||||
|
# Might be able to optimize this by looking at what service_regenconf does
|
||||||
|
# and only do the part that checks file hashes...
|
||||||
|
cmd = "yunohost service regen-conf --dry-run --output-as json --quiet"
|
||||||
|
j = json.loads(subprocess.check_output(cmd.split()))
|
||||||
|
|
||||||
|
# j is something like :
|
||||||
|
# {"postfix": {"applied": {}, "pending": {"/etc/postfix/main.cf": {"status": "modified"}}}
|
||||||
|
|
||||||
|
output = []
|
||||||
|
for app, actions in j.items():
|
||||||
|
for action, files in actions.items():
|
||||||
|
for filename, infos in files.items():
|
||||||
|
if infos["status"] == "modified":
|
||||||
|
output.append(filename)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def manually_modified_files_compared_to_debian_default():
|
||||||
|
|
||||||
|
# from https://serverfault.com/a/90401
|
||||||
|
r = subprocess.check_output("dpkg-query -W -f='${Conffiles}\n' '*' \
|
||||||
|
| awk 'OFS=\" \"{print $2,$1}' \
|
||||||
|
| md5sum -c 2>/dev/null \
|
||||||
|
| awk -F': ' '$2 !~ /OK/{print $1}'", shell=True)
|
||||||
|
return r.strip().split("\n")
|
|
@ -26,12 +26,8 @@
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import yaml
|
import yaml
|
||||||
import json
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from difflib import unified_diff
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from moulinette import m18n
|
from moulinette import m18n
|
||||||
|
@ -39,11 +35,7 @@ from yunohost.utils.error import YunohostError
|
||||||
from moulinette.utils import log, filesystem
|
from moulinette.utils import log, filesystem
|
||||||
|
|
||||||
from yunohost.log import is_unit_operation
|
from yunohost.log import is_unit_operation
|
||||||
from yunohost.hook import hook_callback, hook_list
|
|
||||||
|
|
||||||
BASE_CONF_PATH = '/home/yunohost.conf'
|
|
||||||
BACKUP_CONF_DIR = os.path.join(BASE_CONF_PATH, 'backup')
|
|
||||||
PENDING_CONF_DIR = os.path.join(BASE_CONF_PATH, 'pending')
|
|
||||||
MOULINETTE_LOCK = "/var/run/moulinette_yunohost.lock"
|
MOULINETTE_LOCK = "/var/run/moulinette_yunohost.lock"
|
||||||
|
|
||||||
logger = log.getActionLogger('yunohost.service')
|
logger = log.getActionLogger('yunohost.service')
|
||||||
|
@ -418,251 +410,6 @@ def service_log(name, number=50):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@is_unit_operation([('names', 'service')])
|
|
||||||
def service_regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run=False,
|
|
||||||
list_pending=False):
|
|
||||||
"""
|
|
||||||
Regenerate the configuration file(s) for a service
|
|
||||||
|
|
||||||
Keyword argument:
|
|
||||||
names -- Services name to regenerate configuration of
|
|
||||||
with_diff -- Show differences in case of configuration changes
|
|
||||||
force -- Override all manual modifications in configuration files
|
|
||||||
dry_run -- Show what would have been regenerated
|
|
||||||
list_pending -- List pending configuration files and exit
|
|
||||||
|
|
||||||
"""
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
# Return the list of pending conf
|
|
||||||
if list_pending:
|
|
||||||
pending_conf = _get_pending_conf(names)
|
|
||||||
|
|
||||||
if not with_diff:
|
|
||||||
return pending_conf
|
|
||||||
|
|
||||||
for service, conf_files in pending_conf.items():
|
|
||||||
for system_path, pending_path in conf_files.items():
|
|
||||||
|
|
||||||
pending_conf[service][system_path] = {
|
|
||||||
'pending_conf': pending_path,
|
|
||||||
'diff': _get_files_diff(
|
|
||||||
system_path, pending_path, True),
|
|
||||||
}
|
|
||||||
|
|
||||||
return pending_conf
|
|
||||||
|
|
||||||
if not dry_run:
|
|
||||||
operation_logger.related_to = [('service', x) for x in names]
|
|
||||||
if not names:
|
|
||||||
operation_logger.name_parameter_override = 'all'
|
|
||||||
elif len(names) != 1:
|
|
||||||
operation_logger.name_parameter_override = str(len(operation_logger.related_to)) + '_services'
|
|
||||||
operation_logger.start()
|
|
||||||
|
|
||||||
# Clean pending conf directory
|
|
||||||
if os.path.isdir(PENDING_CONF_DIR):
|
|
||||||
if not names:
|
|
||||||
shutil.rmtree(PENDING_CONF_DIR, ignore_errors=True)
|
|
||||||
else:
|
|
||||||
for name in names:
|
|
||||||
shutil.rmtree(os.path.join(PENDING_CONF_DIR, name),
|
|
||||||
ignore_errors=True)
|
|
||||||
else:
|
|
||||||
filesystem.mkdir(PENDING_CONF_DIR, 0o755, True)
|
|
||||||
|
|
||||||
# Format common hooks arguments
|
|
||||||
common_args = [1 if force else 0, 1 if dry_run else 0]
|
|
||||||
|
|
||||||
# Execute hooks for pre-regen
|
|
||||||
pre_args = ['pre', ] + common_args
|
|
||||||
|
|
||||||
def _pre_call(name, priority, path, args):
|
|
||||||
# create the pending conf directory for the service
|
|
||||||
service_pending_path = os.path.join(PENDING_CONF_DIR, name)
|
|
||||||
filesystem.mkdir(service_pending_path, 0o755, True, uid='root')
|
|
||||||
|
|
||||||
# return the arguments to pass to the script
|
|
||||||
return pre_args + [service_pending_path, ]
|
|
||||||
|
|
||||||
# Don't regen SSH if not specifically specified
|
|
||||||
if not names:
|
|
||||||
names = hook_list('conf_regen', list_by='name',
|
|
||||||
show_info=False)['hooks']
|
|
||||||
names.remove('ssh')
|
|
||||||
|
|
||||||
pre_result = hook_callback('conf_regen', names, pre_callback=_pre_call)
|
|
||||||
|
|
||||||
# Update the services name
|
|
||||||
names = pre_result['succeed'].keys()
|
|
||||||
|
|
||||||
if not names:
|
|
||||||
raise YunohostError('service_regenconf_failed',
|
|
||||||
services=', '.join(pre_result['failed']))
|
|
||||||
|
|
||||||
# Set the processing method
|
|
||||||
_regen = _process_regen_conf if not dry_run else lambda *a, **k: True
|
|
||||||
|
|
||||||
operation_logger.related_to = []
|
|
||||||
|
|
||||||
# Iterate over services and process pending conf
|
|
||||||
for service, conf_files in _get_pending_conf(names).items():
|
|
||||||
if not dry_run:
|
|
||||||
operation_logger.related_to.append(('service', service))
|
|
||||||
|
|
||||||
logger.debug(m18n.n(
|
|
||||||
'service_regenconf_pending_applying' if not dry_run else
|
|
||||||
'service_regenconf_dry_pending_applying',
|
|
||||||
service=service))
|
|
||||||
|
|
||||||
conf_hashes = _get_conf_hashes(service)
|
|
||||||
succeed_regen = {}
|
|
||||||
failed_regen = {}
|
|
||||||
|
|
||||||
for system_path, pending_path in conf_files.items():
|
|
||||||
logger.debug("processing pending conf '%s' to system conf '%s'",
|
|
||||||
pending_path, system_path)
|
|
||||||
conf_status = None
|
|
||||||
regenerated = False
|
|
||||||
|
|
||||||
# Get the diff between files
|
|
||||||
conf_diff = _get_files_diff(
|
|
||||||
system_path, pending_path, True) if with_diff else None
|
|
||||||
|
|
||||||
# Check if the conf must be removed
|
|
||||||
to_remove = True if os.path.getsize(pending_path) == 0 else False
|
|
||||||
|
|
||||||
# Retrieve and calculate hashes
|
|
||||||
system_hash = _calculate_hash(system_path)
|
|
||||||
saved_hash = conf_hashes.get(system_path, None)
|
|
||||||
new_hash = None if to_remove else _calculate_hash(pending_path)
|
|
||||||
|
|
||||||
# -> system conf does not exists
|
|
||||||
if not system_hash:
|
|
||||||
if to_remove:
|
|
||||||
logger.debug("> system conf is already removed")
|
|
||||||
os.remove(pending_path)
|
|
||||||
continue
|
|
||||||
if not saved_hash or force:
|
|
||||||
if force:
|
|
||||||
logger.debug("> system conf has been manually removed")
|
|
||||||
conf_status = 'force-created'
|
|
||||||
else:
|
|
||||||
logger.debug("> system conf does not exist yet")
|
|
||||||
conf_status = 'created'
|
|
||||||
regenerated = _regen(
|
|
||||||
system_path, pending_path, save=False)
|
|
||||||
else:
|
|
||||||
logger.info(m18n.n(
|
|
||||||
'service_conf_file_manually_removed',
|
|
||||||
conf=system_path))
|
|
||||||
conf_status = 'removed'
|
|
||||||
|
|
||||||
# -> system conf is not managed yet
|
|
||||||
elif not saved_hash:
|
|
||||||
logger.debug("> system conf is not managed yet")
|
|
||||||
if system_hash == new_hash:
|
|
||||||
logger.debug("> no changes to system conf has been made")
|
|
||||||
conf_status = 'managed'
|
|
||||||
regenerated = True
|
|
||||||
elif not to_remove:
|
|
||||||
# If the conf exist but is not managed yet, and is not to be removed,
|
|
||||||
# we assume that it is safe to regen it, since the file is backuped
|
|
||||||
# anyway (by default in _regen), as long as we warn the user
|
|
||||||
# appropriately.
|
|
||||||
logger.info(m18n.n('service_conf_now_managed_by_yunohost',
|
|
||||||
conf=system_path))
|
|
||||||
regenerated = _regen(system_path, pending_path)
|
|
||||||
conf_status = 'new'
|
|
||||||
elif force:
|
|
||||||
regenerated = _regen(system_path)
|
|
||||||
conf_status = 'force-removed'
|
|
||||||
else:
|
|
||||||
logger.info(m18n.n('service_conf_file_kept_back',
|
|
||||||
conf=system_path, service=service))
|
|
||||||
conf_status = 'unmanaged'
|
|
||||||
|
|
||||||
# -> system conf has not been manually modified
|
|
||||||
elif system_hash == saved_hash:
|
|
||||||
if to_remove:
|
|
||||||
regenerated = _regen(system_path)
|
|
||||||
conf_status = 'removed'
|
|
||||||
elif system_hash != new_hash:
|
|
||||||
regenerated = _regen(system_path, pending_path)
|
|
||||||
conf_status = 'updated'
|
|
||||||
else:
|
|
||||||
logger.debug("> system conf is already up-to-date")
|
|
||||||
os.remove(pending_path)
|
|
||||||
continue
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.debug("> system conf has been manually modified")
|
|
||||||
if system_hash == new_hash:
|
|
||||||
logger.debug("> new conf is as current system conf")
|
|
||||||
conf_status = 'managed'
|
|
||||||
regenerated = True
|
|
||||||
elif force:
|
|
||||||
regenerated = _regen(system_path, pending_path)
|
|
||||||
conf_status = 'force-updated'
|
|
||||||
else:
|
|
||||||
logger.warning(m18n.n(
|
|
||||||
'service_conf_file_manually_modified',
|
|
||||||
conf=system_path))
|
|
||||||
conf_status = 'modified'
|
|
||||||
|
|
||||||
# Store the result
|
|
||||||
conf_result = {'status': conf_status}
|
|
||||||
if conf_diff is not None:
|
|
||||||
conf_result['diff'] = conf_diff
|
|
||||||
if regenerated:
|
|
||||||
succeed_regen[system_path] = conf_result
|
|
||||||
conf_hashes[system_path] = new_hash
|
|
||||||
if os.path.isfile(pending_path):
|
|
||||||
os.remove(pending_path)
|
|
||||||
else:
|
|
||||||
failed_regen[system_path] = conf_result
|
|
||||||
|
|
||||||
# Check for service conf changes
|
|
||||||
if not succeed_regen and not failed_regen:
|
|
||||||
logger.debug(m18n.n('service_conf_up_to_date', service=service))
|
|
||||||
continue
|
|
||||||
elif not failed_regen:
|
|
||||||
logger.success(m18n.n(
|
|
||||||
'service_conf_updated' if not dry_run else
|
|
||||||
'service_conf_would_be_updated',
|
|
||||||
service=service))
|
|
||||||
|
|
||||||
if succeed_regen and not dry_run:
|
|
||||||
_update_conf_hashes(service, conf_hashes)
|
|
||||||
|
|
||||||
# Append the service results
|
|
||||||
result[service] = {
|
|
||||||
'applied': succeed_regen,
|
|
||||||
'pending': failed_regen
|
|
||||||
}
|
|
||||||
|
|
||||||
# Return in case of dry run
|
|
||||||
if dry_run:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Execute hooks for post-regen
|
|
||||||
post_args = ['post', ] + common_args
|
|
||||||
|
|
||||||
def _pre_call(name, priority, path, args):
|
|
||||||
# append coma-separated applied changes for the service
|
|
||||||
if name in result and result[name]['applied']:
|
|
||||||
regen_conf_files = ','.join(result[name]['applied'].keys())
|
|
||||||
else:
|
|
||||||
regen_conf_files = ''
|
|
||||||
return post_args + [regen_conf_files, ]
|
|
||||||
|
|
||||||
hook_callback('conf_regen', names, pre_callback=_pre_call)
|
|
||||||
|
|
||||||
operation_logger.success()
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _run_service_command(action, service):
|
def _run_service_command(action, service):
|
||||||
"""
|
"""
|
||||||
Run services management command (start, stop, enable, disable, restart, reload)
|
Run services management command (start, stop, enable, disable, restart, reload)
|
||||||
|
@ -860,231 +607,9 @@ def _find_previous_log_file(file):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_files_diff(orig_file, new_file, as_string=False, skip_header=True):
|
|
||||||
"""Compare two files and return the differences
|
|
||||||
|
|
||||||
Read and compare two files. The differences are returned either as a delta
|
|
||||||
in unified diff format or a formatted string if as_string is True. The
|
|
||||||
header can also be removed if skip_header is True.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
if os.path.exists(orig_file):
|
|
||||||
with open(orig_file, 'r') as orig_file:
|
|
||||||
orig_file = orig_file.readlines()
|
|
||||||
else:
|
|
||||||
orig_file = []
|
|
||||||
|
|
||||||
if os.path.exists(new_file):
|
|
||||||
with open(new_file, 'r') as new_file:
|
|
||||||
new_file = new_file.readlines()
|
|
||||||
else:
|
|
||||||
new_file = []
|
|
||||||
|
|
||||||
# Compare files and format output
|
|
||||||
diff = unified_diff(orig_file, new_file)
|
|
||||||
|
|
||||||
if skip_header:
|
|
||||||
try:
|
|
||||||
next(diff)
|
|
||||||
next(diff)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if as_string:
|
|
||||||
return ''.join(diff).rstrip()
|
|
||||||
|
|
||||||
return diff
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_hash(path):
|
|
||||||
"""Calculate the MD5 hash of a file"""
|
|
||||||
|
|
||||||
if not os.path.exists(path):
|
|
||||||
return None
|
|
||||||
|
|
||||||
hasher = hashlib.md5()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(path, 'rb') as f:
|
|
||||||
hasher.update(f.read())
|
|
||||||
return hasher.hexdigest()
|
|
||||||
|
|
||||||
except IOError as e:
|
|
||||||
logger.warning("Error while calculating file '%s' hash: %s", path, e, exc_info=1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_pending_conf(services=[]):
|
|
||||||
"""Get pending configuration for service(s)
|
|
||||||
|
|
||||||
Iterate over the pending configuration directory for given service(s) - or
|
|
||||||
all if empty - and look for files inside. Each file is considered as a
|
|
||||||
pending configuration file and therefore must be in the same directory
|
|
||||||
tree than the system file that it replaces.
|
|
||||||
The result is returned as a dict of services with pending configuration as
|
|
||||||
key and a dict of `system_conf_path` => `pending_conf_path` as value.
|
|
||||||
|
|
||||||
"""
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
if not os.path.isdir(PENDING_CONF_DIR):
|
|
||||||
return result
|
|
||||||
|
|
||||||
if not services:
|
|
||||||
services = os.listdir(PENDING_CONF_DIR)
|
|
||||||
|
|
||||||
for name in services:
|
|
||||||
service_pending_path = os.path.join(PENDING_CONF_DIR, name)
|
|
||||||
|
|
||||||
if not os.path.isdir(service_pending_path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
path_index = len(service_pending_path)
|
|
||||||
service_conf = {}
|
|
||||||
|
|
||||||
for root, dirs, files in os.walk(service_pending_path):
|
|
||||||
for filename in files:
|
|
||||||
pending_path = os.path.join(root, filename)
|
|
||||||
service_conf[pending_path[path_index:]] = pending_path
|
|
||||||
|
|
||||||
if service_conf:
|
|
||||||
result[name] = service_conf
|
|
||||||
else:
|
|
||||||
# remove empty directory
|
|
||||||
shutil.rmtree(service_pending_path, ignore_errors=True)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _get_conf_hashes(service):
|
|
||||||
"""Get the registered conf hashes for a service"""
|
|
||||||
|
|
||||||
services = _get_services()
|
|
||||||
|
|
||||||
if service not in services:
|
|
||||||
logger.debug("Service %s is not in services.yml yet.", service)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
elif services[service] is None or 'conffiles' not in services[service]:
|
|
||||||
logger.debug("No configuration files for service %s.", service)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
else:
|
|
||||||
return services[service]['conffiles']
|
|
||||||
|
|
||||||
|
|
||||||
def _update_conf_hashes(service, hashes):
|
|
||||||
"""Update the registered conf hashes for a service"""
|
|
||||||
logger.debug("updating conf hashes for '%s' with: %s",
|
|
||||||
service, hashes)
|
|
||||||
services = _get_services()
|
|
||||||
service_conf = services.get(service, {})
|
|
||||||
|
|
||||||
# Handle the case where services[service] is set to null in the yaml
|
|
||||||
if service_conf is None:
|
|
||||||
service_conf = {}
|
|
||||||
|
|
||||||
service_conf['conffiles'] = hashes
|
|
||||||
services[service] = service_conf
|
|
||||||
_save_services(services)
|
|
||||||
|
|
||||||
|
|
||||||
def _process_regen_conf(system_conf, new_conf=None, save=True):
|
|
||||||
"""Regenerate a given system configuration file
|
|
||||||
|
|
||||||
Replace a given system configuration file by a new one or delete it if
|
|
||||||
new_conf is None. A backup of the file - keeping its directory tree - will
|
|
||||||
be done in the backup conf directory before any operation if save is True.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if save:
|
|
||||||
backup_path = os.path.join(BACKUP_CONF_DIR, '{0}-{1}'.format(
|
|
||||||
system_conf.lstrip('/'), datetime.utcnow().strftime("%Y%m%d.%H%M%S")))
|
|
||||||
backup_dir = os.path.dirname(backup_path)
|
|
||||||
|
|
||||||
if not os.path.isdir(backup_dir):
|
|
||||||
filesystem.mkdir(backup_dir, 0o755, True)
|
|
||||||
|
|
||||||
shutil.copy2(system_conf, backup_path)
|
|
||||||
logger.debug(m18n.n('service_conf_file_backed_up',
|
|
||||||
conf=system_conf, backup=backup_path))
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not new_conf:
|
|
||||||
os.remove(system_conf)
|
|
||||||
logger.debug(m18n.n('service_conf_file_removed',
|
|
||||||
conf=system_conf))
|
|
||||||
else:
|
|
||||||
system_dir = os.path.dirname(system_conf)
|
|
||||||
|
|
||||||
if not os.path.isdir(system_dir):
|
|
||||||
filesystem.mkdir(system_dir, 0o755, True)
|
|
||||||
|
|
||||||
shutil.copyfile(new_conf, system_conf)
|
|
||||||
logger.debug(m18n.n('service_conf_file_updated',
|
|
||||||
conf=system_conf))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Exception while trying to regenerate conf '%s': %s", system_conf, e, exc_info=1)
|
|
||||||
if not new_conf and os.path.exists(system_conf):
|
|
||||||
logger.warning(m18n.n('service_conf_file_remove_failed',
|
|
||||||
conf=system_conf),
|
|
||||||
exc_info=1)
|
|
||||||
return False
|
|
||||||
|
|
||||||
elif new_conf:
|
|
||||||
try:
|
|
||||||
# From documentation:
|
|
||||||
# Raise an exception if an os.stat() call on either pathname fails.
|
|
||||||
# (os.stats returns a series of information from a file like type, size...)
|
|
||||||
copy_succeed = os.path.samefile(system_conf, new_conf)
|
|
||||||
except:
|
|
||||||
copy_succeed = False
|
|
||||||
finally:
|
|
||||||
if not copy_succeed:
|
|
||||||
logger.warning(m18n.n('service_conf_file_copy_failed',
|
|
||||||
conf=system_conf, new=new_conf),
|
|
||||||
exc_info=1)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def manually_modified_files():
|
|
||||||
|
|
||||||
# We do this to have --quiet, i.e. don't throw a whole bunch of logs
|
|
||||||
# just to fetch this...
|
|
||||||
# Might be able to optimize this by looking at what service_regenconf does
|
|
||||||
# and only do the part that checks file hashes...
|
|
||||||
cmd = "yunohost service regen-conf --dry-run --output-as json --quiet"
|
|
||||||
j = json.loads(subprocess.check_output(cmd.split()))
|
|
||||||
|
|
||||||
# j is something like :
|
|
||||||
# {"postfix": {"applied": {}, "pending": {"/etc/postfix/main.cf": {"status": "modified"}}}
|
|
||||||
|
|
||||||
output = []
|
|
||||||
for app, actions in j.items():
|
|
||||||
for action, files in actions.items():
|
|
||||||
for filename, infos in files.items():
|
|
||||||
if infos["status"] == "modified":
|
|
||||||
output.append(filename)
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def _get_journalctl_logs(service, number="all"):
|
def _get_journalctl_logs(service, number="all"):
|
||||||
try:
|
try:
|
||||||
return subprocess.check_output("journalctl -xn -u {0} -n{1}".format(service, number), shell=True)
|
return subprocess.check_output("journalctl -xn -u {0} -n{1}".format(service, number), shell=True)
|
||||||
except:
|
except:
|
||||||
import traceback
|
import traceback
|
||||||
return "error while get services logs from journalctl:\n%s" % traceback.format_exc()
|
return "error while get services logs from journalctl:\n%s" % traceback.format_exc()
|
||||||
|
|
||||||
|
|
||||||
def manually_modified_files_compared_to_debian_default():
|
|
||||||
|
|
||||||
# from https://serverfault.com/a/90401
|
|
||||||
r = subprocess.check_output("dpkg-query -W -f='${Conffiles}\n' '*' \
|
|
||||||
| awk 'OFS=\" \"{print $2,$1}' \
|
|
||||||
| md5sum -c 2>/dev/null \
|
|
||||||
| awk -F': ' '$2 !~ /OK/{print $1}'", shell=True)
|
|
||||||
return r.strip().split("\n")
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue