#! /usr/bin/python3
# -*- coding: utf-8 -*-
#
##########################################################################
# Maj-Auto - Manage automatique update of EOLE server
# Copyright © 2013-2025 Pôle de compétences EOLE <eole@ac-dijon.fr>
#
# License CeCILL:
#  * in french: http://www.cecill.info/licences/Licence_CeCILL_V2-fr.html
#  * in english http://www.cecill.info/licences/Licence_CeCILL_V2-en.html
##########################################################################

import sys
import argparse
import atexit
import time
import locale

from os import unlink, environ, system
from subprocess import Popen, PIPE
from os.path import basename, isfile, isdir

from configparser import ConfigParser

from creole import reconfigure, fonctionseole
from creole.config import NEED_MAJ_AUTO_LOCKFILE
from creole.client import CreoleClient, TimeoutCreoleClientError, NotFoundError, CreoleClientError
from creole.error import UserExit, UserExitError

from creole.eoleversion import EOLE_RELEASE, LAST_RELEASE, EOLE_VERSION

from pyeole.lock import acquire, release, is_locked
from pyeole.log import init_logging, set_formatter
from pyeole.ihm import question_ouinon, only_root, catch_signal
from pyeole.encode import normalize
from pyeole.process import system_code

from pyeole.pkg import EolePkg, _configure_sources_mirror, report

from pyeole import scriptargs

from pyeole.i18n import i18n

_ = i18n('creole')

#import logging

log = None

only_root()

# Run scripts in directories
RUNPARTS_PATH_PRE = '/usr/share/eole/majauto_pre'
RUNPARTS_PATH = '/usr/share/eole/majauto'
RUNPARTS_CMD = '/bin/run-parts --exit-on-error {args} -v {folder}'

try:
    # FIXME : refactorer le système de lock de zephir-client (ref #6660)
    from zephir.lib_zephir import lock, unlock
    zephir_libs = True
except Exception:
    zephir_libs = False

def release_lock():
    if zephir_libs:
        unlock('maj')
    if is_locked('majauto', level='system'):
        release('majauto', level='system')

def user_exit(*args, **kwargs):
    """
    sortie utilisateur "propre"
    """
    log.warning(_('! Abandoning configuration !'))
    log.warning(_('System may be in an incoherent state.\n\n'))
    raise UserExitError()


def parse_cmdline():
    """Parse commande line.
    """
    parser = argparse.ArgumentParser(prog='Maj-Auto|Query-Auto',
                                     description=_("Manage EOLE server automatic update"),
                                     parents=[scriptargs.logging('info')],
                                     add_help=False)

    parser.add_argument('-h', '--help',
                        action='help',
                        help=_("show this help message and exit"))
    parser.add_argument('-n', '--dry-run',
                        action='store_true',
                        help=_("run in dry-run mode (force to True when using Query-Auto)."))
    parser.add_argument('-f', '--force',
                        action='store_true',
                        help=_("bypass Zephir authorizations."))
    parser.add_argument('-F', '--force-update',
                        action='store_true',
                        help=_("update your server without any confirmation."))

    parser.add_argument('-s', '--simulate',
                        action='store_true',
                        help=_("ask apt-get to simulate packages installation"))

    # Level of upgrade
    parser.add_argument('-C', '--candidat', default=False,
                           nargs='*',
                           choices=['eole', 'envole'],
                           help=_(u"use testing packages."))

    parser.add_argument('-D', '--devel', default=False,
                           nargs='*',
                           choices=['eole', 'envole'],
                           help=_("use development packages."))

    parser.add_argument('--release',
                        help=argparse.SUPPRESS)

    # Action when upgrade is OK
    parser.add_argument('-r', '--reconfigure',
                        action='store_true',
                        help=_("run reconfigure on successful upgrade."))

    parser.add_argument('-R', '--reboot',
                        action='store_true',
                        help=_("run reconfigure on successful upgrade and reboot if necessary (implies -r)."))
    parser.add_argument('--download', action='store_true',
                        help=_('only download packages in cache.'))
    # Mirror selection
    parser.add_argument('-S', '--eole-mirror',
                        help=_("EOLE repository server."))
    parser.add_argument('-U', '--ubuntu-mirror',
                        help=_("Ubuntu repository server."))
    parser.add_argument('-V', '--envole-mirror',
                        help=_("Envole repository server."))

    # sortie EAD
    parser.add_argument('-W', action='store_true',
                        help=_("specific output for EAD."))
    # mode sans creoled
    parser.add_argument('-i', '--ignore', action='store_true',
                        help=_("ignore local configuration if creoled not responding."))


    opts = parser.parse_args()

    if getattr(opts, 'level', None) is None:
        opts.level = 'updates'
    if opts.verbose:
        opts.log_level = 'info'
    if opts.debug:
        opts.log_level = 'debug'

    if opts.reboot:
        opts.reconfigure = True

    return opts


def main():
    global log
    opts = parse_cmdline()
    if opts.W:
        # variable set for pyeole.ansiprint
        environ['ModeTxt'] = 'yes'
    reporting = not (opts.dry_run or opts.simulate or opts.download)
    if not reporting:
        z_proc = 'QUERY-MAJ'
        log = init_logging(name=basename(sys.argv[0]), level=opts.log_level)
        pkg_log = init_logging(name='pyeole.pkg', level=opts.log_level)
        diag_log = init_logging(name='pyeole.diagnose', level=opts.log_level)
    else:
        z_proc = 'MAJ'
        report_file = '/var/lib/eole/reports/rapport-maj.log'
        if isfile(report_file):
            unlink(report_file)
        log = init_logging(name=basename(sys.argv[0]), level=opts.log_level,
                           filename=report_file)
        pkg_log = init_logging(name='pyeole.pkg', level=opts.log_level,
                               filename=report_file)
        diag_log = init_logging(name='pyeole.diagnose', level=opts.log_level,
                               filename=report_file)
        set_formatter(log, 'file', 'brief')
        set_formatter(log, 'file', 'with-levelname-date')
        set_formatter(pkg_log, 'file', 'with-levelname-date')
        set_formatter(diag_log, 'file', 'with-levelname-date')
        report(2)
    locale.setlocale(locale.LC_TIME, "fr_FR.utf8")
    log.info(_('Update at {0}').format(time.strftime("%A %d %B %Y %H:%M:%S")))
    raised_err = None
    error_msg = None
    try:
        # gestion du ctrl+c
        catch_signal(user_exit)
        acquire('majauto', level='system')
        atexit.register(release_lock)
        informations = get_creole_informations(opts)

        # affichage du header
        head = "*** {module} {version}"
        if informations["uai"]:
            head += " ({uai})"
        head += " ***\n"
        log.info(head.format(**informations))

        ask_raising_level(opts, informations, z_proc)

        fonctionseole.zephir("INIT", "Début{0}".format(informations["z_level"]), z_proc)
        if zephir_libs and not fonctionseole.init_proc('MAJ'):
            if opts.force:
                fonctionseole.zephir("MSG",
                                     "Mise à jour forcée par l'utilisateur",
                                     z_proc)
            else:
                log.warning(_("Update is locked, please contact Zéphir administrator"))
                log.warning(_("Use -f option if you want to force execution"))
                raise UserExitError()
            lock('maj')

        if isdir(RUNPARTS_PATH_PRE):
            args = '--arg="dry-run"' if opts.dry_run else ''
            print((_('Running scripts {0}').format(RUNPARTS_PATH_PRE)))
            code = system(RUNPARTS_CMD.format(args=args, folder=RUNPARTS_PATH_PRE))
            if code != 0:
                raise Exception(_('Error {0}').format(RUNPARTS_PATH_PRE))

        PKGMGR = EolePkg('apt', ignore=opts.ignore)
        if opts.dry_run:
            PKGMGR.set_option('APT::Get::Simulate', 'true')

        PKGMGR.check()

        #serveurs à utiliser pour les dépôts Ubuntu et EOLE
        _configure_sources_mirror(PKGMGR.pkgmgr, ubuntu=opts.ubuntu_mirror,
                                  eole=opts.eole_mirror, envole=opts.envole_mirror,
                                  ignore=opts.ignore,
                                  release=informations["version"], eole_level=informations["eole_level"],
                                  envole_level=informations["envole_level"])


        PKGMGR.update(silent=True)
        upgrades = PKGMGR.get_upgradable_list()

        install = 0
        upgrade = 0
        delete = 0
        for container, packages in list(upgrades.items()):
            if not packages:
                continue
            for name, isInstalled, candidateVersion in packages:
                if isInstalled:
                    if candidateVersion is None:
                        delete += 1
                    else:
                        upgrade += 1
                else:
                    install += 1

        total_pkg = install+upgrade

        headers = []
        if total_pkg == 0:
            log.info(_("Update successful."))
            log.info(_("Nothing to install."))
            fonctionseole.zephir("FIN",
                                 "Aucun paquet à installer{0}".format(informations["z_level"]),
                                 z_proc)
            if reporting:
                report(3)

            if not (isfile(NEED_MAJ_AUTO_LOCKFILE) and not opts.dry_run and not opts.download and not opts.simulate):
                sys.exit(0)

        headers.append(_("{0} new,", "{0} news,", install).format(install))
        headers.append(_("{0} upgrade,", "{0} upgrades,", upgrade).format(upgrade))
        headers.append(_("{0} delete", "{0} deletes", delete).format(delete))
        log.info(' '.join(headers))

        for line in PKGMGR.list_upgrade(upgrades=upgrades):
            log.info(line)

        if opts.dry_run:

            if informations["module_instancie"] == 'oui':
                try:
                    smtpconfig = ConfigParser()
                    smtpconfig.read('/etc/eole/server.cfg')
                    if smtpconfig.get('update', 'notification') in ('tous', 'queryauto'):
                        fromaddr = smtpconfig.get('smtp', 'system_mail_from')
                        toaddrs = smtpconfig.get('smtp', 'system_mail_to')
                        mailhost = smtpconfig.get('smtp', 'mailhost')
                        hostname = smtpconfig.get('global', 'hostname')
                        hostip = smtpconfig.get('global', 'ip')

                        subject = _('{0} available updates for server {1} ({2})').format(total_pkg, hostname, hostip)
                        smtp_params = {'subject': subject,
                                       'fromaddr': fromaddr,
                                       'toaddrs': [toaddrs],
                                       'secure': (),
                                       'mailhost': mailhost,
                                      }
                        query_logger = init_logging(level='warning', smtp=smtp_params, console=False)
                        query_logger.warn('\n'.join(PKGMGR.list_upgrade(upgrades=upgrades)))
                except Exception as err:
                    log.warning(_('Error while attempting notifying by mail: {0}').format(err))

            fonctionseole.zephir("FIN",
                                 "{0} paquets à mettre à jour{1}".format(total_pkg, informations["z_level"]),
                                 z_proc)
            sys.exit(0)

        if opts.download:
            for container, packages in list(upgrades.items()):
                if not packages:
                    continue
                pkgs = []
                for name, isInstalled, candidateVersion in packages:
                    pkgs.append(name)
                PKGMGR.fetch_archives(container=container, packages=pkgs)
                fonctionseole.zephir("FIN",
                                     "{0} paquets téléchargés{1}".format(total_pkg, informations["z_level"]),
                                     z_proc)

        elif opts.simulate:
            PKGMGR.dist_upgrade(simulate=opts.simulate)
            fonctionseole.zephir("FIN",
                                 "{0} paquets mis à jour (simulation){1}".format(total_pkg, informations["z_level"]),
                                  z_proc)

        else:
            PKGMGR.download_upgrade()
            PKGMGR.dist_upgrade(simulate=opts.simulate)
            log.info(_("Update successful."))
            fonctionseole.zephir("FIN",
                                 "{0} paquets mis à jour{1}".format(total_pkg, informations["z_level"]),
                                  z_proc)
            if isdir(RUNPARTS_PATH):
                args = '--arg="dry-run"' if opts.dry_run else ''
                print((_('Running scripts {0}').format(RUNPARTS_PATH)))
                code = system(RUNPARTS_CMD.format(args=args, folder=RUNPARTS_PATH))
                if code != 0:
                    raise Exception(_('Error {0}').format(RUNPARTS_PATH))

            if isfile(NEED_MAJ_AUTO_LOCKFILE):
                log.debug('Unlink {}'.format(NEED_MAJ_AUTO_LOCKFILE))
                unlink(NEED_MAJ_AUTO_LOCKFILE)

            if opts.release:
                ret_code = system('/usr/share/zephir/scripts/upgrade_distrib.py --auto')
                if ret_code != 0:
                    error_msg = str('erreur à la mise à jour vers la release {0}'.format(opts.release))
                else:
                    log.info(_('Upgrade post Maj-Release, please wait'))
                    release('majauto', level='system')
                    cmd = ['/usr/bin/Maj-Auto', '-F']
                    process = Popen(cmd, stdin=PIPE, stderr=PIPE, stdout=PIPE, shell=False)
                    ret_code = process.wait()
                    if ret_code != 0:
                        error_msg = str(_('error in post maj release'))
            if opts.reconfigure:
                # rechargement des modules python (#7832)
                # cf. http://code.activestate.com/recipes/81731-reloading-all-modules/
                if 'init_modules' in globals():
                    for m in [x for x in list(sys.modules.keys()) if x not in init_modules]:
                        del(sys.modules[m])
                else:
                    init_modules = list(sys.modules.keys())
                fonctionseole.zephir("MSG",
                                     "Reconfiguration automatique",
                                     z_proc)
            elif not opts.release:
                log.warning(_("At least one packages has been updated,"
                              " use command [reconfigure] to apply modifications."))
                fonctionseole.zephir("MSG",
                                     "Reconfiguration du serveur à planifier",
                                     z_proc)

    except (UserExit, UserExitError) as err:
        if reporting:
            report(1, 'Stopped by user')
        fonctionseole.zephir("FIN", "Abandon par l'utilisateur", z_proc)
        sys.exit(1)

    except (TimeoutCreoleClientError, NotFoundError, CreoleClientError) as err:
        clue = _(". If restarting creoled service does not help, try {} command with '-i' option.")
        error_msg = str(err) + clue.format('Query-Auto' if opts.dry_run else 'Maj-Auto')
        raised_err = err

    except Exception as err:
        log.debug('error: {}'.format(err), exc_info=True)
        error_msg = str(err)
        raised_err = err
    else:
        if reporting:
            report(0, reconf=opts.reconfigure)

    if error_msg is not None:
        fonctionseole.zephir("ERR", error_msg, z_proc, console=False)
        if reporting:
            if raised_err is not None:
                report(1, normalize(raised_err))
            else:
                report(1, error_msg)
        if log is None:
            # No logger defined, error in argument parsing
            raise
        if opts.log_level == 'debug' and raised_err is not None:
            log.error(raised_err, exc_info=True)
        else:
            log.error(error_msg)
        sys.exit(1)

    if opts.reconfigure:
        cmd = ['/usr/bin/reconfigure']
        if opts.reboot:
            cmd.append('-a')
        release_lock()
        if system_code(cmd):
            fonctionseole.zephir("ERR", 'error', z_proc, console=False)
            if reporting:
                report(1, 'error')
            sys.exit(1)


def get_creole_informations(opts):
    eole_level = 'stable'
    envole_level = 'stable'
    client = CreoleClient()
    try:
        version = client.get_creole('eole_release')
        module = client.get_creole('eole_module')
        uai = client.get_creole('numero_etab')
        module_instancie = client.get_creole('module_instancie')
    except (TimeoutCreoleClientError, NotFoundError, CreoleClientError) as err:
        if not opts.ignore:
            raise err
        version = EOLE_RELEASE
        module = 'module'
        uai = None
        module_instancie = 'non'

    if opts.candidat is not False and opts.devel is not False:
        if opts.candidat == opts.devel:
            log.warning(_(u"argument -D/--devel not allowed with argument -C/--candidat with no value or same value"))
            raise UserExit()

    z_level = ""
    if opts.candidat is not False:
        z_level = " en candidate"
        # Gestion du niveau par dépôt (16110)
        if len(opts.candidat) == 0:
            # Si on ne précise aucun dépôt tout le monde va en candidat
            eole_level = 'proposed'
            envole_level = 'proposed'
        else:
            # Sinon on vérifie dépôt par dépôt, les dépôts non précisés restent en stable
            if 'eole' in opts.candidat:
                eole_level = 'proposed'
            if 'envole' in opts.candidat:
                envole_level = 'proposed'
    if opts.devel is not False:
        z_level = " en devel"
        # Gestion du niveau par dépôt (16110)
        if len(opts.devel) == 0:
            # Si on ne précise aucun dépôt tout le monde vas en candidat
            eole_level = 'unstable'
            envole_level = 'unstable'
        else:
            # Sinon on vérifie dépôt par dépôt, les dépôts non précisés restent en stable
            if 'eole' in opts.devel:
                eole_level = 'unstable'
            if 'envole' in opts.devel:
                envole_level = 'unstable'
    if opts.release:
        current_release = int(EOLE_RELEASE.split('.')[-1])
        new_release = opts.release.split('.')
        if len(new_release) != 3 or \
                '.'.join(new_release[0:2]) != EOLE_VERSION or \
                int(new_release[2]) not in list(range(current_release+1, int(LAST_RELEASE) + 1)):
            raise Exception(_('Unknown release number'))
        z_level += " en {0}".format(opts.release)
        version = opts.release
    return {'version': version,
            'module': module,
            'uai': uai,
            'module_instancie': module_instancie,
            'z_level': z_level,
            'eole_level': eole_level,
            'envole_level': envole_level,
            }


def ask_raising_level(opts, informations, z_proc):
    if opts.force_update:
        return

    raising_level = ''

    if opts.release:
        raising_level += _("(CHANGE RELEASE LEVEL)")

    if 'unstable' in [informations["eole_level"], informations["envole_level"]]:
        if opts.candidat:
            if 'eole' in opts.candidat:
                raising_level += " envole "
            if 'envole' in opts.candidat:
                raising_level += " eole "
        raising_level += _("(UNSTABLE VERSION)")

    if 'proposed' in [informations["eole_level"], informations["envole_level"]]:
        if opts.devel:
            if 'envole' in opts.devel:
                raising_level += " eole "
            if 'eole' in opts.devel:
                raising_level += " envole "
        raising_level += _("(TESTING VERSION)")

    if not raising_level:
        return

    log.warning(_("{0} - Raising update level is irreversible.").format(raising_level))
    try:
        assert question_ouinon(_("Do you wish to proceed?")) == 'oui'
        fonctionseole.zephir("MSG",
                             "Mise à jour{0} forcée par l'utilisateur".format(informations["z_level"]),
                             z_proc)
    except (AssertionError, EOFError) as err:
        log.warning(_("Cancelling!"))
        raise UserExit()


if __name__ == '__main__':
    main()
