def _list_diagnosis_categories(): hooks_raw = hook_list("diagnosis", list_by="priority", show_info=True)["hooks"] hooks = [] for _, some_hooks in sorted(hooks_raw.items(), key=lambda h: int(h[0])): for name, info in some_hooks.items(): hooks.append((name, info["path"])) return hooks
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 """ 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, ] ssh_explicitly_specified = isinstance(names, list) and "ssh" in names # By default, we regen everything if not names: names = hook_list("conf_regen", list_by="name", show_info=False)["hooks"] # Dirty hack for legacy code : avoid attempting to regen the conf for # glances because it got removed ... This is only needed *once* # during the upgrade from 3.7 to 3.8 because Yunohost will attempt to # regen glance's conf *before* it gets automatically removed from # services.yml (which will happens only during the regen-conf of # 'yunohost', so at the very end of the regen-conf cycle) Anyway, # this can be safely removed once we're in >= 4.0 if "glances" in names: names.remove("glances") # [Optimization] We compute and feed the domain list to the conf regen # hooks to avoid having to call "yunohost domain list" so many times which # ends up in wasted time (about 3~5 seconds per call on a RPi2) from yunohost.domain import domain_list env = {} # Well we can only do domain_list() if postinstall is done ... # ... but hooks that effectively need the domain list are only # called only after the 'installed' flag is set so that's all good, # though kinda tight-coupled to the postinstall logic :s if os.path.exists("/etc/yunohost/installed"): env["YNH_DOMAINS"] = " ".join(domain_list()["domains"]) pre_result = hook_callback("conf_regen", names, pre_callback=_pre_call, env=env) # 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)) if dry_run: logger.debug(m18n.n("regenconf_pending_applying", category=category)) else: logger.debug(m18n.n("regenconf_dry_pending_applying", category=category)) conf_hashes = _get_conf_hashes(category) succeed_regen = {} failed_regen = {} # Here we are doing some weird legacy shit # The thing is, on some very old or specific setup, the sshd_config file # was absolutely not managed by the regenconf ... # But we now want to make sure that this file is managed. # However, we don't want to overwrite a specific custom sshd_config # which may make the admin unhappy ... # So : if the hash for this file does not exists, we set the hash as the # hash of the pending configuration ... # That way, the file will later appear as manually modified. sshd_config = "/etc/ssh/sshd_config" if ( category == "ssh" and sshd_config not in conf_hashes and sshd_config in conf_files ): conf_hashes[sshd_config] = _calculate_hash(conf_files[sshd_config]) _update_conf_hashes(category, conf_hashes) # Consider the following scenario: # - you add a domain foo.bar # - the regen-conf creates file /etc/dnsmasq.d/foo.bar # - the admin manually *deletes* /etc/dnsmasq.d/foo.bar # - the file is now understood as manually deleted because there's the old file hash in regenconf.yml # # ... so far so good, that's the expected behavior. # # But then: # - the admin remove domain foo.bar entirely # - but now the hash for /etc/dnsmasq.d/foo.bar is *still* in # regenconf.yml and and the file is still flagged as manually # modified/deleted... And the user cannot even do anything about it # except removing the hash in regenconf.yml... # # Expected behavior: it should forget about that # hash because dnsmasq's regen-conf doesn't say anything about what's # the state of that file so it should assume that it should be deleted. # # - then the admin tries to *re-add* foo.bar ! # - ... but because the file is still flagged as manually modified # the regen-conf refuses to re-create the file. # # Excepted behavior : the regen-conf should have forgot about the hash # from earlier and this wouldnt happen. # ------ # conf_files contain files explicitly set by the current regen conf run # conf_hashes contain all files known from the past runs # we compare these to get the list of stale hashes and flag the file as # "should be removed" stale_files = set(conf_hashes.keys()) - set(conf_files.keys()) stale_files_with_non_empty_hash = [f for f in stale_files if conf_hashes.get(f)] for f in stale_files_with_non_empty_hash: conf_files[f] = None # </> End discussion about stale file hashes force_update_hashes_for_this_category = False 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 pending_path and 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) # -> configuration was previously managed by yunohost but should now # be removed / unmanaged if system_path in stale_files_with_non_empty_hash: # File is already deleted, so let's just silently forget about this hash entirely if not system_hash: logger.debug("> forgetting about stale file/hash") conf_hashes[system_path] = None conf_status = "forget-about-it" regenerated = True # Otherwise there's still a file on the system but it's not managed by # Yunohost anymore... But if user requested --force we shall # force-erase it elif force: logger.debug("> force-remove stale file") regenerated = _regen(system_path) conf_status = "force-removed" # Otherwise, flag the file as manually modified else: logger.warning( m18n.n("regenconf_file_manually_modified", conf=system_path) ) conf_status = "modified" # -> system conf does not exists elif not system_hash: if to_remove: logger.debug("> system conf is already removed") os.remove(pending_path) conf_hashes[system_path] = None conf_status = "forget-about-it" force_update_hashes_for_this_category = True continue elif 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 and system_path == sshd_config and not ssh_explicitly_specified ): logger.warning(m18n.n("regenconf_need_to_explicitly_specify_ssh")) conf_status = "modified" 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 pending_path and 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: if not dry_run: logger.success(m18n.n("regenconf_updated", category=category)) else: logger.success(m18n.n("regenconf_would_be_updated", category=category)) if (succeed_regen or force_update_hashes_for_this_category) 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, env=env) operation_logger.success() return result