diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index cbe959b55..fabdcb923 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -1623,6 +1623,32 @@ tools: full: --force action: store_true + ### tools_regen_conf() + regen-conf: + action_help: Regenerate the configuration file(s) + api: PUT /tools/regenconf + arguments: + names: + help: Categories to regenerate configuration of (all by default) + nargs: "*" + metavar: NAME + -d: + full: --with-diff + help: Show differences in case of configuration changes + action: store_true + -f: + full: --force + help: Override all manual modifications in configuration files + action: store_true + -n: + full: --dry-run + help: Show what would have been regenerated + action: store_true + -p: + full: --list-pending + help: List pending configuration files and exit + action: store_true + subcategories: migrations: diff --git a/data/templates/yunohost/services.yml b/data/templates/yunohost/services.yml index 62509e1e9..0d79b182f 100644 --- a/data/templates/yunohost/services.yml +++ b/data/templates/yunohost/services.yml @@ -20,8 +20,6 @@ mysql: glances: {} ssh: log: /var/log/auth.log -ssl: - status: null metronome: log: [/var/log/metronome/metronome.log,/var/log/metronome/metronome.err] slapd: @@ -34,10 +32,9 @@ yunohost-firewall: need_lock: true nslcd: log: /var/log/syslog -nsswitch: - status: null -yunohost: - status: null +nsswitch: null +ssl: null +yunohost: null bind9: null tahoe-lafs: null memcached: null diff --git a/debian/postinst b/debian/postinst index df7112b9d..83220ae0b 100644 --- a/debian/postinst +++ b/debian/postinst @@ -12,7 +12,7 @@ do_configure() { bash /usr/share/yunohost/hooks/conf_regen/15-nginx init else echo "Regenerating configuration, this might take a while..." - yunohost service regen-conf --output-as none + yunohost tools regen-conf --output-as none echo "Launching migrations.." yunohost tools migrations migrate --auto diff --git a/locales/en.json b/locales/en.json index 6fbe722c7..9c4183adb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -262,7 +262,7 @@ "log_selfsigned_cert_install": "Install self signed certificate on '{}' domain", "log_letsencrypt_cert_renew": "Renew '{}' Let's encrypt certificate", "log_service_enable": "Enable '{}' service", - "log_service_regen_conf": "Regenerate system configurations '{}'", + "log_regen_conf": "Regenerate system configurations '{}'", "log_user_create": "Add '{}' user", "log_user_delete": "Delete '{}' user", "log_user_update": "Update information of '{}' user", @@ -299,6 +299,7 @@ "migration_description_0006_sync_admin_and_root_passwords": "Synchronize admin and root passwords", "migration_description_0007_ssh_conf_managed_by_yunohost_step1": "Let the SSH configuration be managed by YunoHost (step 1, automatic)", "migration_description_0008_ssh_conf_managed_by_yunohost_step2": "Let the SSH configuration be managed by YunoHost (step 2, manual)", + "migration_description_0009_decouple_regenconf_from_services": "Decouple the regen-conf mechanism from services", "migration_0003_backward_impossible": "The stretch migration cannot be reverted.", "migration_0003_start": "Starting migration to Stretch. The logs will be available in {logfile}.", "migration_0003_patching_sources_list": "Patching the sources.lists…", @@ -324,6 +325,7 @@ "migration_0008_dsa": " - the DSA key will be disabled. Hence, you might need to invalidate a spooky warning from your SSH client, and recheck the fingerprint of your server;", "migration_0008_warning": "If you understand those warnings and agree to let YunoHost override your current configuration, run the migration. Otherwise, you can also skip the migration - though it is not recommended.", "migration_0008_no_warning": "No major risk has been indentified about overriding your SSH configuration - but we can't be absolutely sure ;)! If you agree to let YunoHost override your current configuration, run the migration. Otherwise, you can also skip the migration - though it is not recommended.", + "migration_0009_not_needed": "This migration already happened somehow ? Skipping.", "migrations_backward": "Migrating backward.", "migrations_bad_value_for_target": "Invalid number for target argument, available migrations numbers are 0 or {}", "migrations_cant_reach_migration_file": "Can't access migrations files at path %s", @@ -391,6 +393,21 @@ "port_available": "Port {port:d} is available", "port_unavailable": "Port {port:d} is not available", "recommend_to_add_first_user": "The post-install is finished but YunoHost needs at least one user to work correctly, you should add one using 'yunohost user create' or the admin interface.", + "regenconf_file_backed_up": "The configuration file '{conf}' has been backed up to '{backup}'", + "regenconf_file_copy_failed": "Unable to copy the new configuration file '{new}' to '{conf}'", + "regenconf_file_kept_back": "The configuration file '{conf}' is expected to be deleted by regen-conf (category {category}) but has been kept back.", + "regenconf_file_manually_modified": "The configuration file '{conf}' has been manually modified and will not be updated", + "regenconf_file_manually_removed": "The configuration file '{conf}' has been manually removed and will not be created", + "regenconf_file_remove_failed": "Unable to remove the configuration file '{conf}'", + "regenconf_file_removed": "The configuration file '{conf}' has been removed", + "regenconf_file_updated": "The configuration file '{conf}' has been updated", + "regenconf_now_managed_by_yunohost": "The configuration file '{conf}' is now managed by YunoHost (category {category}).", + "regenconf_up_to_date": "The configuration is already up-to-date for category '{category}'", + "regenconf_updated": "The configuration has been updated for category '{category}'", + "regenconf_would_be_updated": "The configuration would have been updated for category '{category}'", + "regenconf_dry_pending_applying": "Checking pending configuration which would have been applied for category '{category}'…", + "regenconf_failed": "Unable to regenerate the configuration for category(s): {categories}", + "regenconf_pending_applying": "Applying pending configuration for category '{category}'…", "restore_action_required": "You must specify something to restore", "restore_already_installed_app": "An app is already installed with the id '{app:s}'", "restore_app_failed": "Unable to restore the app '{app:s}'", @@ -419,18 +436,6 @@ "service_already_started": "Service '{service:s}' has already been started", "service_already_stopped": "Service '{service:s}' has already been stopped", "service_cmd_exec_failed": "Unable to execute command '{command:s}'", - "service_conf_file_backed_up": "The configuration file '{conf}' has been backed up to '{backup}'", - "service_conf_file_copy_failed": "Unable to copy the new configuration file '{new}' to '{conf}'", - "service_conf_file_kept_back": "The configuration file '{conf}' is expected to be deleted by service {service} but has been kept back.", - "service_conf_file_manually_modified": "The configuration file '{conf}' has been manually modified and will not be updated", - "service_conf_file_manually_removed": "The configuration file '{conf}' has been manually removed and will not be created", - "service_conf_file_remove_failed": "Unable to remove the configuration file '{conf}'", - "service_conf_file_removed": "The configuration file '{conf}' has been removed", - "service_conf_file_updated": "The configuration file '{conf}' has been updated", - "service_conf_now_managed_by_yunohost": "The configuration file '{conf}' is now managed by YunoHost.", - "service_conf_up_to_date": "The configuration is already up-to-date for service '{service}'", - "service_conf_updated": "The configuration has been updated for service '{service}'", - "service_conf_would_be_updated": "The configuration would have been updated for service '{service}'", "service_description_avahi-daemon": "allows to reach your server using yunohost.local on your local network", "service_description_dnsmasq": "handles domain name resolution (DNS)", "service_description_dovecot": "allows e-mail client to access/fetch email (via IMAP and POP3)", @@ -454,9 +459,7 @@ "service_enable_failed": "Unable to enable service '{service:s}'\n\nRecent service logs:{logs:s}", "service_enabled": "The service '{service:s}' has been enabled", "service_no_log": "No log to display for service '{service:s}'", - "service_regenconf_dry_pending_applying": "Checking pending configuration which would have been applied for service '{service}'…", - "service_regenconf_failed": "Unable to regenerate the configuration for service(s): {services}", - "service_regenconf_pending_applying": "Applying pending configuration for service '{service}'…", + "service_regen_conf_is_deprecated": "'yunohost service regen-conf' is deprecated! Please use 'yunohost tools regen-conf' instead.", "service_remove_failed": "Unable to remove service '{service:s}'", "service_removed": "The service '{service:s}' has been removed", "service_reload_failed": "Unable to reload service '{service:s}'\n\nRecent service logs:{logs:s}", diff --git a/src/yunohost/backup.py b/src/yunohost/backup.py index 6f969327b..c1a4f6adc 100644 --- a/src/yunohost/backup.py +++ b/src/yunohost/backup.py @@ -50,7 +50,7 @@ from yunohost.hook import ( ) from yunohost.monitor import binary_to_human from yunohost.tools import tools_postinstall -from yunohost.service import service_regen_conf +from yunohost.regenconf import regen_conf from yunohost.log import OperationLogger from functools import reduce @@ -1212,7 +1212,7 @@ class RestoreManager(): else: operation_logger.success() - service_regen_conf() + regen_conf() def _restore_apps(self): """Restore all apps targeted""" diff --git a/src/yunohost/certificate.py b/src/yunohost/certificate.py index 855910b8a..d7e8c0157 100644 --- a/src/yunohost/certificate.py +++ b/src/yunohost/certificate.py @@ -43,7 +43,8 @@ from yunohost.utils.network import get_public_ip from moulinette import m18n from yunohost.app import app_ssowatconf -from yunohost.service import _run_service_command, service_regen_conf +from yunohost.service import _run_service_command +from yunohost.regenconf import regen_conf from yunohost.log import OperationLogger logger = getActionLogger('yunohost.certmanager') @@ -806,7 +807,7 @@ def _enable_certificate(domain, new_cert_folder): if os.path.isfile('/etc/yunohost/installed'): # regen nginx conf to be sure it integrates OCSP Stapling # (We don't do this yet if postinstall is not finished yet) - service_regen_conf(names=['nginx']) + regen_conf(names=['nginx']) _run_service_command("reload", "nginx") @@ -924,7 +925,7 @@ def _regen_dnsmasq_if_needed(): break if do_regen: - service_regen_conf(["dnsmasq"]) + regen_conf(["dnsmasq"]) def _name_self_CA(): diff --git a/src/yunohost/data_migrations/0003_migrate_to_stretch.py b/src/yunohost/data_migrations/0003_migrate_to_stretch.py index 438393216..0db719e15 100644 --- a/src/yunohost/data_migrations/0003_migrate_to_stretch.py +++ b/src/yunohost/data_migrations/0003_migrate_to_stretch.py @@ -10,9 +10,9 @@ from moulinette.utils.filesystem import read_file from yunohost.tools import Migration from yunohost.app import unstable_apps -from yunohost.service import (_run_service_command, - manually_modified_files, - manually_modified_files_compared_to_debian_default) +from yunohost.service import _run_service_command +from yunohost.regenconf import (manually_modified_files, + manually_modified_files_compared_to_debian_default) from yunohost.utils.filesystem import free_space_in_directory from yunohost.utils.packages import get_installed_version from yunohost.utils.network import get_network_interfaces diff --git a/src/yunohost/data_migrations/0007_ssh_conf_managed_by_yunohost_step1.py b/src/yunohost/data_migrations/0007_ssh_conf_managed_by_yunohost_step1.py index d188ff024..feffdc27c 100644 --- a/src/yunohost/data_migrations/0007_ssh_conf_managed_by_yunohost_step1.py +++ b/src/yunohost/data_migrations/0007_ssh_conf_managed_by_yunohost_step1.py @@ -3,15 +3,12 @@ import re from shutil import copyfile -from moulinette import m18n from moulinette.utils.log import getActionLogger from moulinette.utils.filesystem import mkdir, rm from yunohost.tools import Migration -from yunohost.service import service_regen_conf, \ - _get_conf_hashes, \ - _calculate_hash, \ - _run_service_command +from yunohost.service import _run_service_command +from yunohost.regenconf import regen_conf from yunohost.settings import settings_set from yunohost.utils.error import YunohostError @@ -60,7 +57,7 @@ class MyMigration(Migration): if os.path.exists('/etc/yunohost/from_script'): rm('/etc/yunohost/from_script') copyfile(SSHD_CONF, '/etc/ssh/sshd_config.bkp') - service_regen_conf(names=['ssh'], force=True) + regen_conf(names=['ssh'], force=True) copyfile('/etc/ssh/sshd_config.bkp', SSHD_CONF) # Restart ssh and backward if it fail diff --git a/src/yunohost/data_migrations/0008_ssh_conf_managed_by_yunohost_step2.py b/src/yunohost/data_migrations/0008_ssh_conf_managed_by_yunohost_step2.py index 0976f1354..8984440bd 100644 --- a/src/yunohost/data_migrations/0008_ssh_conf_managed_by_yunohost_step2.py +++ b/src/yunohost/data_migrations/0008_ssh_conf_managed_by_yunohost_step2.py @@ -6,9 +6,8 @@ from moulinette.utils.log import getActionLogger from moulinette.utils.filesystem import chown from yunohost.tools import Migration -from yunohost.service import service_regen_conf, \ - _get_conf_hashes, \ - _calculate_hash +from yunohost.regenconf import _get_conf_hashes, _calculate_hash +from yunohost.regenconf import regen_conf from yunohost.settings import settings_set, settings_get from yunohost.utils.error import YunohostError from yunohost.backup import ARCHIVES_PATH @@ -36,7 +35,7 @@ class MyMigration(Migration): def migrate(self): settings_set("service.ssh.allow_deprecated_dsa_hostkey", False) - service_regen_conf(names=['ssh'], force=True) + regen_conf(names=['ssh'], force=True) # Update local archives folder permissions, so that # admin can scp archives out of the server diff --git a/src/yunohost/data_migrations/0009_decouple_regenconf_from_services.py b/src/yunohost/data_migrations/0009_decouple_regenconf_from_services.py new file mode 100644 index 000000000..d552d7c9c --- /dev/null +++ b/src/yunohost/data_migrations/0009_decouple_regenconf_from_services.py @@ -0,0 +1,42 @@ +import os + +from moulinette import m18n +from moulinette.utils.log import getActionLogger + +from moulinette.utils.filesystem import read_file +from yunohost.service import _get_services, _save_services +from yunohost.regenconf import _update_conf_hashes, REGEN_CONF_FILE + +from yunohost.tools import Migration + +logger = getActionLogger('yunohost.migration') + + +class MyMigration(Migration): + """ + Decouple the regen conf mechanism from the concept of services + """ + + def migrate(self): + + if "conffiles" not in read_file("/etc/yunohost/services.yml") \ + or os.path.exists(REGEN_CONF_FILE): + logger.warning(m18n.n("migration_0009_not_needed")) + return + + # For all services + services = _get_services() + for service, infos in services.items(): + # If there are some conffiles (file hashes) + if "conffiles" in infos.keys(): + # Save them using the new regen conf thingy + _update_conf_hashes(service, infos["conffiles"]) + # And delete the old conffile key from the service infos + del services[service]["conffiles"] + + # (Actually save the modification of services) + _save_services(services) + + def backward(self): + + pass diff --git a/src/yunohost/domain.py b/src/yunohost/domain.py index 70a4ef5c9..a39f45a0e 100644 --- a/src/yunohost/domain.py +++ b/src/yunohost/domain.py @@ -34,7 +34,7 @@ from moulinette.utils.log import getActionLogger import yunohost.certificate -from yunohost.service import service_regen_conf +from yunohost.regenconf import regen_conf from yunohost.utils.network import get_public_ip from yunohost.log import is_unit_operation from yunohost.hook import hook_callback @@ -112,7 +112,7 @@ def domain_add(operation_logger, auth, domain, dyndns=False): # Don't regen these conf if we're still in postinstall if os.path.exists('/etc/yunohost/installed'): - service_regen_conf(names=['nginx', 'metronome', 'dnsmasq', 'postfix', 'rspamd']) + regen_conf(names=['nginx', 'metronome', 'dnsmasq', 'postfix', 'rspamd']) app_ssowatconf(auth) except Exception: @@ -165,7 +165,7 @@ def domain_remove(operation_logger, auth, domain, force=False): else: raise YunohostError('domain_deletion_failed') - service_regen_conf(names=['nginx', 'metronome', 'dnsmasq', 'postfix']) + regen_conf(names=['nginx', 'metronome', 'dnsmasq', 'postfix']) app_ssowatconf(auth) hook_callback('post_domain_remove', args=[domain]) diff --git a/src/yunohost/regenconf.py b/src/yunohost/regenconf.py new file mode 100644 index 000000000..48129634a --- /dev/null +++ b/src/yunohost/regenconf.py @@ -0,0 +1,555 @@ +# -*- 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 moulinette.utils.filesystem import read_file + +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') +REGEN_CONF_FILE = '/etc/yunohost/regenconf.yml' + +logger = log.getActionLogger('yunohost.regenconf') + + +# FIXME : those ain't just services anymore ... what are we supposed to do with this ... +# FIXME : check for all reference of 'service' close to operation_logger stuff +@is_unit_operation([('names', 'configuration')]) +def regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run=False, + list_pending=False): + """ + Regenerate the configuration file(s) + + Keyword argument: + names -- Categories 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 + + """ + + # Legacy code to automatically run the migration + # This is required because regen_conf is called before the migration call + # in debian's postinst script + if os.path.exists("/etc/yunohost/installed") \ + and ("conffiles" in read_file("/etc/yunohost/services.yml") \ + or not os.path.exists(REGEN_CONF_FILE)): + from yunohost.tools import _get_migration_by_name + migration = _get_migration_by_name("decouple_regenconf_from_services") + migration.migrate() + + result = {} + + # Return the list of pending conf + if list_pending: + pending_conf = _get_pending_conf(names) + + if not with_diff: + return pending_conf + + for category, conf_files in pending_conf.items(): + for system_path, pending_path in conf_files.items(): + + pending_conf[category][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 = [('configuration', 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)) + '_categories' + 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 category + category_pending_path = os.path.join(PENDING_CONF_DIR, name) + filesystem.mkdir(category_pending_path, 0o755, True, uid='root') + + # return the arguments to pass to the script + return pre_args + [category_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) + + # Keep only the hook names with at least one success + names = [hook for hook, infos in pre_result.items() + if any(result["state"] == "succeed" for result in infos.values())] + + # FIXME : what do in case of partial success/failure ... + if not names: + ret_failed = [hook for hook, infos in pre_result.items() + if any(result["state"] == "failed" for result in infos.values())] + raise YunohostError('regenconf_failed', + categories=', '.join(ret_failed)) + + # Set the processing method + _regen = _process_regen_conf if not dry_run else lambda *a, **k: True + + operation_logger.related_to = [] + + # Iterate over categories and process pending conf + for category, conf_files in _get_pending_conf(names).items(): + if not dry_run: + operation_logger.related_to.append(('configuration', category)) + + logger.debug(m18n.n( + 'regenconf_pending_applying' if not dry_run else + 'regenconf_dry_pending_applying', + category=category)) + + conf_hashes = _get_conf_hashes(category) + 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( + 'regenconf_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('regenconf_now_managed_by_yunohost', + conf=system_path, category=category)) + regenerated = _regen(system_path, pending_path) + conf_status = 'new' + elif force: + regenerated = _regen(system_path) + conf_status = 'force-removed' + else: + logger.info(m18n.n('regenconf_file_kept_back', + conf=system_path, category=category)) + 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( + 'regenconf_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 category conf changes + if not succeed_regen and not failed_regen: + logger.debug(m18n.n('regenconf_up_to_date', category=category)) + continue + elif not failed_regen: + logger.success(m18n.n( + 'regenconf_updated' if not dry_run else + 'regenconf_would_be_updated', + category=category)) + + if succeed_regen and not dry_run: + _update_conf_hashes(category, conf_hashes) + + # Append the category results + result[category] = { + '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 category + 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_regenconf_infos(): + """ + Get a dict of regen conf informations + """ + try: + with open(REGEN_CONF_FILE, 'r') as f: + return yaml.load(f) + except: + return {} + + +def _save_regenconf_infos(infos): + """ + Save the regen conf informations + Keyword argument: + categories -- A dict containing the regenconf infos + """ + try: + with open(REGEN_CONF_FILE, 'w') as f: + yaml.safe_dump(infos, f, default_flow_style=False) + except Exception as e: + logger.warning('Error while saving regenconf infos, 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(categories=[]): + """Get pending configuration for categories + + Iterate over the pending configuration directory for given categories - 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 categories 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 categories: + categories = os.listdir(PENDING_CONF_DIR) + + for name in categories: + category_pending_path = os.path.join(PENDING_CONF_DIR, name) + + if not os.path.isdir(category_pending_path): + continue + + path_index = len(category_pending_path) + category_conf = {} + + for root, dirs, files in os.walk(category_pending_path): + for filename in files: + pending_path = os.path.join(root, filename) + category_conf[pending_path[path_index:]] = pending_path + + if category_conf: + result[name] = category_conf + else: + # remove empty directory + shutil.rmtree(category_pending_path, ignore_errors=True) + + return result + + +def _get_conf_hashes(category): + """Get the registered conf hashes for a category""" + + categories = _get_regenconf_infos() + + if category not in categories: + logger.debug("category %s is not in categories.yml yet.", category) + return {} + + elif categories[category] is None or 'conffiles' not in categories[category]: + logger.debug("No configuration files for category %s.", category) + return {} + + else: + return categories[category]['conffiles'] + + +def _update_conf_hashes(category, hashes): + """Update the registered conf hashes for a category""" + logger.debug("updating conf hashes for '%s' with: %s", + category, hashes) + + categories = _get_regenconf_infos() + category_conf = categories.get(category, {}) + + # Handle the case where categories[category] is set to null in the yaml + if category_conf is None: + category_conf = {} + + category_conf['conffiles'] = hashes + categories[category] = category_conf + _save_regenconf_infos(categories) + + +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('regenconf_file_backed_up', + conf=system_conf, backup=backup_path)) + + try: + if not new_conf: + os.remove(system_conf) + logger.debug(m18n.n('regenconf_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('regenconf_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('regenconf_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('regenconf_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 the regen conf does + # and only do the part that checks file hashes... + cmd = "yunohost tools 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") diff --git a/src/yunohost/service.py b/src/yunohost/service.py index b139c0b6d..c3d78bc1f 100644 --- a/src/yunohost/service.py +++ b/src/yunohost/service.py @@ -26,13 +26,9 @@ import os import time import yaml -import json import subprocess -import shutil -import hashlib from glob import glob -from difflib import unified_diff from datetime import datetime from moulinette import m18n @@ -40,11 +36,7 @@ from yunohost.utils.error import YunohostError from moulinette.utils import log, filesystem 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" logger = log.getActionLogger('yunohost.service') @@ -424,253 +416,25 @@ def service_log(name, number=50): return result -@is_unit_operation([('names', 'service')]) -def service_regen_conf(operation_logger, names=[], with_diff=False, force=False, dry_run=False, +def service_regen_conf(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 + services = _get_services() - """ - result = {} + if isinstance(names, str): + names = [names] - # Return the list of pending conf - if list_pending: - pending_conf = _get_pending_conf(names) + for name in names: + if name not in services.keys(): + raise YunohostError('service_unknown', service=name) - if not with_diff: - return pending_conf + if names is []: + names = services.keys() - for service, conf_files in pending_conf.items(): - for system_path, pending_path in conf_files.items(): + logger.warning(m18n.n("service_regen_conf_is_deprecated")) - 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) - - # Keep only the hook names with at least one success - names = [hook for hook, infos in pre_result.items() - if any(result["state"] == "succeed" for result in infos.values())] - - # FIXME : what do in case of partial success/failure ... - if not names: - ret_failed = [hook for hook, infos in pre_result.items() - if any(result["state"] == "failed" for result in infos.values())] - raise YunohostError('service_regenconf_failed', - services=', '.join(ret_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 + from yunohost.regenconf import regen_conf + return regen_conf(names, with_diff, force, dry_run, list_pending) def _run_service_command(action, service): @@ -870,231 +634,9 @@ def _find_previous_log_file(file): 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"): try: return subprocess.check_output("journalctl -xn -u {0} -n{1}".format(service, number), shell=True) except: import traceback 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") diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index 0fb097805..329b91808 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -48,7 +48,8 @@ from yunohost.app import app_fetchlist, app_info, app_upgrade, app_ssowatconf, a from yunohost.domain import domain_add, domain_list, _get_maindomain, _set_maindomain from yunohost.dyndns import _dyndns_available, _dyndns_provides from yunohost.firewall import firewall_upnp -from yunohost.service import service_status, service_regen_conf, service_log, service_start, service_enable +from yunohost.service import service_status, service_log, service_start, service_enable +from yunohost.regenconf import regen_conf from yunohost.monitor import monitor_disk, monitor_system from yunohost.utils.packages import ynh_packages_version from yunohost.utils.network import get_public_ip @@ -213,7 +214,7 @@ def tools_maindomain(operation_logger, auth, new_domain=None): # Regen configurations try: with open('/etc/yunohost/installed', 'r'): - service_regen_conf() + regen_conf() except IOError: pass @@ -331,7 +332,7 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, operation_logger.start() logger.info(m18n.n('yunohost_installing')) - service_regen_conf(['nslcd', 'nsswitch'], force=True) + regen_conf(['nslcd', 'nsswitch'], force=True) # Initialize LDAP for YunoHost # TODO: Improve this part by integrate ldapinit into conf_regen hook @@ -382,7 +383,7 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, os.system('chmod 644 /etc/ssowat/conf.json.persistent') # Create SSL CA - service_regen_conf(['ssl'], force=True) + regen_conf(['ssl'], force=True) ssl_dir = '/usr/share/yunohost/yunohost-config/ssl/yunoCA' # (Update the serial so that it's specific to this very instance) os.system("openssl rand -hex 19 > %s/serial" % ssl_dir) @@ -411,7 +412,7 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, logger.success(m18n.n('yunohost_ca_creation_success')) # New domain config - service_regen_conf(['nsswitch'], force=True) + regen_conf(['nsswitch'], force=True) domain_add(auth, domain, dyndns) tools_maindomain(auth, domain) @@ -439,7 +440,7 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, service_enable("yunohost-firewall") service_start("yunohost-firewall") - service_regen_conf(force=True) + regen_conf(force=True) # Restore original ssh conf, as chosen by the # admin during the initial install @@ -456,13 +457,18 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, else: # We need to explicitly ask the regen conf to regen ssh # (by default, i.e. first argument = None, it won't because it's too touchy) - service_regen_conf(names=["ssh"], force=True) + regen_conf(names=["ssh"], force=True) logger.success(m18n.n('yunohost_configured')) logger.warning(m18n.n('recommend_to_add_first_user')) +def tools_regen_conf(names=[], with_diff=False, force=False, dry_run=False, + list_pending=False): + return regen_conf(names, with_diff, force, dry_run, list_pending) + + def tools_update(ignore_apps=False, ignore_packages=False): """ Update apps & package cache, then display changelog @@ -758,7 +764,7 @@ def tools_diagnosis(auth, private=False): # Domains diagnosis['private']['domains'] = domain_list(auth)['domains'] - diagnosis['private']['regen_conf'] = service_regen_conf(with_diff=True, dry_run=True) + diagnosis['private']['regen_conf'] = regen_conf(with_diff=True, dry_run=True) try: diagnosis['security'] = {