Projet

Général

Profil

zephirservice.py

Tom Ricci, 29/03/2024 09:36

Télécharger (25,4 ko)

 
1
# -*- coding: UTF-8 -*-
2
###########################################################################
3
# Eole NG - 2007
4
# Copyright Pole de Competence Eole  (Ministere Education - Academie Dijon)
5
# Licence CeCill  cf /root/LicenceEole.txt
6
# eole@ac-dijon.fr
7
###########################################################################
8

    
9
"""
10
Services Twisted de collection et de publication de données.
11
"""
12

    
13
import locale, gettext, os, pwd, shutil, random
14
from pathlib2 import Path
15
from glob import glob
16
import cjson
17
import traceback
18

    
19
# install locales early
20
from zephir.monitor.agentmanager import ZEPHIRAGENTS_DATADIR
21
APP = 'zephir-agents'
22
DIR = os.path.join(ZEPHIRAGENTS_DATADIR, 'i18n')
23
gettext.install(APP, DIR, unicode=False)
24

    
25

    
26
from twisted.application import internet, service
27
from twisted.internet import utils, reactor
28
from twisted.web import resource, server, static, util, xmlrpc
29
from twisted.python import syslog
30

    
31
from zephir.monitor.agentmanager import config as cfg
32
from zephir.monitor.agentmanager.util import ensure_dirs, md5file, get_md5files, log
33
from zephir.monitor.agentmanager.web_resources import ZephirServerResource
34
from zephir.monitor.agentmanager.clientmanager import ClientManager
35

    
36
try:
37
    import zephir.zephir_conf.zephir_conf as conf_zeph
38
    from zephir.lib_zephir import zephir_proxy, convert, zephir_dir, update_sudoers, charset
39
    from zephir.lib_zephir import log as zeph_log
40
    registered = 1
41
except:
42
    # serveur non enregistré sur zephir
43
    registered = 0
44

    
45
class ZephirService(service.MultiService):
46
    """Main Twisted service for Zephir apps"""
47

    
48
    def __init__(self, config, root_resource=None, serve_static=False):
49
        """config will be completed by default values"""
50
        service.MultiService.__init__(self)
51
        self.config = cfg.DEFAULT_CONFIG.copy()
52
        self.config.update(config)
53
        self.updater = self.publisher = None
54
        # mise à jour des scripts clients dans sudoers
55
        if registered:
56
            update_sudoers()
57
        # parent web server
58
        if root_resource is None:
59
            self.root_resource = resource.Resource()
60
            webserver = internet.TCPServer(self.config['webserver_port'],
61
                                                server.Site(self.root_resource))
62
            webserver.setServiceParent(service.IServiceCollection(self))
63
        else:
64
            self.root_resource = root_resource
65
        # serve global static files
66
        if serve_static:
67
            self.root_resource.putChild('static',
68
                                        static.File(self.config['static_web_dir']))
69

    
70

    
71
    # subservices factory methods
72

    
73
    def with_updater(self):
74
        assert self.updater is None
75
        self.updater = UpdaterService(self.config, self, self.root_resource)
76
        return self
77

    
78
    def with_publisher(self):
79
        assert self.publisher is None
80
        self.publisher = PublisherService(self.config, self, self.root_resource)
81
        return self
82

    
83
    def with_updater_and_publisher(self):
84
        assert self.updater is None
85
        assert self.publisher is None
86
        self.updater = UpdaterService(self.config, self, self.root_resource)
87
        self.publisher = PublisherService(self.config, self, self.root_resource,
88
                                          show_clients_page = False,
89
                                          live_agents={self.config['host_ref']: self.updater.agents})
90
        return self
91

    
92

    
93

    
94

    
95
class UpdaterService(service.MultiService, xmlrpc.XMLRPC):
96
    """Schedules measures, data serialisation and upload."""
97

    
98
    def __init__(self, config, parent, root_resource):
99
        """config should be complete"""
100
        service.MultiService.__init__(self)
101
        xmlrpc.XMLRPC.__init__(self)
102
        self.old_obs = None
103
        self.config = config
104
        # updates site.cfg file
105
        self.update_static_data()
106
        # start subservices
107
        loc, enc = locale.getdefaultlocale()
108
        log.msg(_('default locale: %s encoding: %s') % (loc, enc))
109
        if enc == 'utf':
110
            log.msg(_('Warning: locale encoding %s broken in RRD graphs, set e.g: LC_ALL=fr_FR') % enc)
111
        self.agents = self.load_agents()
112
        # attach to parent service
113
        self.setServiceParent(service.IServiceCollection(parent))
114
        root_resource.putChild('xmlrpc', self)
115

    
116
    def startService(self):
117
        """initialize zephir services"""
118
        service.MultiService.startService(self)
119
        reactor.callLater(2,self.schedule_all)
120
        # mise à jour du préfixe de log (twisted par défaut)
121
        # FIX : on conserve la référence à l'ancien observer pour
122
        # éviter les pb à la fermeture du service
123
        self.old_obs = None
124
        if len(log.theLogPublisher.observers) >= 1:
125
            self.old_obs = log.theLogPublisher.observers[0]
126
        try:
127
            from zephir.backend import config as conf_zeph
128
            log_prefix = 'zephir_backend'
129
        except:
130
            log_prefix = 'zephiragents'
131
        new_obs = syslog.SyslogObserver(log_prefix, options=syslog.DEFAULT_OPTIONS, facility=syslog.DEFAULT_FACILITY)
132
        log.addObserver(new_obs.emit)
133
        log.removeObserver(self.old_obs)
134
        if registered != 0:
135
            # on est enregistré sur zephir => initiation de
136
            # la création et l'envoi d'archives
137
            self.setup_uucp()
138
            # dans le cas ou un reboot a été demandé, on indique que le redémarrage est bon
139
            if os.path.isfile(os.path.join(zephir_dir,'reboot.lck')):
140
                try:
141
                    zeph_log('REBOOT',0,'redémarrage du serveur terminé')
142
                    os.unlink(os.path.join(zephir_dir,'reboot.lck'))
143
                except:
144
                    pass
145

    
146
    def stopService(self):
147
        """stops zephir services"""
148
        if self.old_obs:
149
            log.removeObserver(log.theLogPublisher.observers[0])
150
            log.addObserver(self.old_obs)
151
        service.MultiService.stopService(self)
152

    
153
    def load_agents(self):
154
        """Charge tous les agents du répertoire de configurations."""
155
        log.msg(_("Loading agents from %s...") % self.config['config_dir'])
156
        loaded_agents = {}
157
        list_agents = glob(os.path.join(self.config['config_dir'], "*.agent"))
158
        for f in list_agents:
159
            log.msg(_("  from %s:") % os.path.basename(f))
160
            h = { 'AGENTS': None }
161
            execfile(f, globals(), h)
162
            assert h.has_key('AGENTS')
163
            for a in h['AGENTS']:
164
                assert not loaded_agents.has_key(a.name)
165
                # init agent data and do a first archive
166
                a.init_data(os.path.join(self.config['state_dir'],
167
                                         self.config['host_ref'],
168
                                         a.name))
169
                a.manager = self
170
                a.archive()
171
                loaded_agents[a.name] = a # /!\ écrasement des clés
172
                log.msg(_("    %s, period %d") % (a.name, a.period))
173
        log.msg(_("Loaded."))
174
        return loaded_agents
175

    
176

    
177
    # scheduling measures
178

    
179
    def schedule(self, agent_name):
180
        """Planifie les mesures périodiques d'un agent."""
181
        assert self.agents.has_key(agent_name)
182
        if self.agents[agent_name].period > 0:
183
            timer = internet.TimerService(self.agents[agent_name].period,
184
                                          self.wakeup_for_measure, agent_name)
185
            timer.setName(agent_name)
186
            timer.setServiceParent(service.IServiceCollection(self))
187

    
188

    
189
    def wakeup_for_measure(self, agent_name):
190
        """Callback pour les mesures planifiées."""
191
        assert self.agents.has_key(agent_name)
192
        # log.debug("Doing scheduled measure on " + agent_name)
193
        self.agents[agent_name].scheduled_measure()
194

    
195

    
196
    def schedule_all(self):
197
        """Planifie tous les agents chargés.
198
        Démarre le cycle de mesures périodiques de chaque agent
199
        chargé. La première mesure est prise immédiatement.
200
        """
201
        for agent_name in self.agents.keys():
202
            # charge les actions disponibles (standard en premier, puis les actions locales)
203
            # les actions locales écrasent les actions standard si les 2 existent
204
            for action_dir in (os.path.join(self.config['action_dir'],'eole'), self.config['action_dir']):
205
                f_actions = os.path.join(action_dir, "%s.actions" % agent_name)
206
                if os.path.isfile(f_actions):
207
                    actions = {}
208
                    execfile(f_actions, globals(), actions)
209
                    for item in actions.keys():
210
                        if item.startswith('action_'):
211
                            setattr(self.agents[agent_name], item, actions[item])
212
            # self.wakeup_for_measure(agent_name) # first measure at launch
213
            self.schedule(agent_name)
214

    
215

    
216
    def timer_for_agent_named(self, agent_name):
217
        assert self.agents.has_key(agent_name)
218
        return self.getServiceNamed(agent_name)
219

    
220

    
221
    # data upload to zephir server
222

    
223
    def setup_uucp(self):
224
        ensure_dirs(self.config['uucp_dir'])
225
        self.update_static_data()
226
        # récupération du délai de connexion à zephir
227
        try:
228
            reload(conf_zeph)
229
            # supression des éventuels répertoires de stats invalides
230
            # par ex, en cas de désinscription zephir 'manuelle'.
231

    
232
            # sur zephir : on garde toujours 0 pour éviter les conflits avec les serveurs enregistrés
233
            if not os.path.isdir('/var/lib/zephir'):
234
                for st_dir in os.listdir(self.config['state_dir']):
235
                    if st_dir != str(conf_zeph.id_serveur):
236
                        shutil.rmtree(os.path.join(self.config['state_dir'],st_dir))
237
            # vérification sur zephir du délai de connexion
238
            period = convert(zephir_proxy.serveurs.get_timeout(conf_zeph.id_serveur)[1])
239
        except:
240
            period = 0
241

    
242
        if period < 30:
243
            period = self.config['upload_period']
244
            log.msg(_('Using default period : %s seconds') % period)
245
        # on ajoute un décalage aléatoire (entre 30 secondes et period) au premier démarrage
246
        # (pour éviter trop de connexions simultanées si le service est relancé par crontab)
247
        delay = random.randrange(30,period)
248
        reactor.callLater(delay,self.wakeup_for_upload)
249

    
250
    def update_static_data(self):
251
        original = os.path.join(self.config['config_dir'], 'site.cfg')
252
        if os.path.isfile(original):
253
            destination = cfg.client_data_dir(self.config, self.config['host_ref'])
254
            ensure_dirs(destination)
255
            need_copy = False
256
            try:
257
                org_mtime = os.path.getmtime(original)
258
                dest_mtime = os.path.getmtime(os.path.join(destination, 'site.cfg'))
259
            except OSError:
260
                need_copy = True
261
            if need_copy or (org_mtime > dest_mtime):
262
                shutil.copy(original, destination)
263

    
264
    def wakeup_for_upload(self, recall=True):
265
        # relecture du délai de connexion sur zephir
266
        try:
267
            reload(conf_zeph)
268
            period = convert(zephir_proxy.serveurs.get_timeout(conf_zeph.id_serveur)[1])
269
        except:
270
            period = 0
271
        # on relance la fonction dans le délai demandé
272
        if period < 30:
273
            period = self.config['upload_period']
274
            log.msg(_('Using default period : %s seconds') % period)
275
        # on ajoute un décalage au premier démarrage
276
        # (pour éviter trop de connexions simultanées si le service est relancé par crontab)
277
        if recall:
278
            reactor.callLater(period,self.wakeup_for_upload)
279

    
280
        # virer l'ancienne archive du rép. uucp
281
        for agent in self.agents.values():
282
            agent.archive()
283
            # agent.reset_max_status()
284
        self.update_static_data()
285
        # archiver dans rép. uucp, donner les droits en lecture sur l'archive
286
        try:
287
            assert conf_zeph.id_serveur != 0
288
            client_dir = os.path.join(self.config['tmp_data_dir'],str(conf_zeph.id_serveur))
289
        except:
290
            client_dir = os.path.join(self.config['tmp_data_dir'],self.config['host_ref'])
291
        try:
292
            # purge du répertoire temporaire
293
            if os.path.isdir(client_dir):
294
                shutil.rmtree(client_dir)
295
            os.makedirs(client_dir)
296
        except: # non existant
297
            pass
298
        args = ['-Rf',os.path.abspath(os.path.join(cfg.client_data_dir(self.config, self.config['host_ref']),'site.cfg'))]
299
        ignore_file = os.path.abspath(os.path.join(self.config['state_dir'],'ignore_list'))
300
        if os.path.exists(ignore_file):
301
            args.append(ignore_file)
302
        # on ne copie que les données des agents instanciés
303
        # cela évite de remonter par exemple les stats rvp si le service a été désactivé
304
        for agent_name in self.agents.keys():
305
            args.append(os.path.abspath(cfg.agent_data_dir(self.config, self.config['host_ref'],agent_name)))
306
        args.append(os.path.abspath(client_dir))
307
        res = utils.getProcessOutput('/bin/cp', args = args)
308
        res.addCallbacks(self._make_archive,
309
                         lambda x: log.msg(_("/!\ copy failed (%s)\n"
310
                                             "data: %s")
311
                                           % (x, self.config['state_dir'])))
312

    
313
    def _check_md5(self):
314
        def to_bytes(objet):
315
            """Transforme les objets unicode contenus dans un objet en bytes
316
            """
317
            if isinstance(objet, tuple):
318
                l = []
319
                for item in objet:
320
                    l.append(to_bytes(item))
321
                return '({})'.format(', '.join(l))
322
            if isinstance(objet, list):
323
                l = []
324
                for item in objet:
325
                    l.append(to_bytes(item))
326
                return '[{}]'.format(', '.join(l))
327
            if isinstance(objet, dict):
328
                dico={}
329
                for cle in objet:
330
                    dico[to_bytes(cle)] = to_bytes(objet[cle])
331
                return '{{{}}}'.format(', '.join(['{}: {}'.format(el[0], el[1]) for el in sorted(dico.items())]))
332
            if isinstance(objet, unicode):
333
                string =  objet.encode(charset)
334
                return "'{}'".format(string)
335
            if isinstance(objet, int):
336
                return str(objet)
337
            if isinstance(objet, float):
338
                return str(objet)
339
            if objet == None:
340
                return 'None'
341
            return objet
342

    
343
        # calcul de sommes md5 pour config.eol et les patchs
344
        rep_src = "/usr/share/eole/creole"
345
        rep_conf = "/etc/eole"
346
        data = []
347
        try:
348
            for src, dst, pattern in get_md5files(cfg.distrib_version):
349
                if src == 'variables.eol':
350
                    # cas particulier : variables.eol, on génère le fichier à chaque fois
351
                    orig_eol = os.path.join(rep_conf, 'config.eol')
352
                    if os.path.isfile(orig_eol):
353
                        var_eol = os.path.join(rep_src, 'variables.eol')
354
                        # on crée un fichier avec variable:valeur ordonné par nom de variable
355
                        conf = cjson.decode(file(orig_eol).read(), all_unicode=True)
356
                        var_names = conf.keys()
357
                        var_names.sort()
358
                        f_var = file(var_eol, 'w')
359
                        for var_name in var_names:
360
                            if var_name not in ('mode_zephir', '___version___'):
361
                                # test supplémentaires au cas où d'autres 'fausses variables'
362
                                # que ___version___ seraient ajoutées.
363
                                # cf : https://dev-eole.ac-dijon.fr/issues/10548
364
                                if type(conf[var_name]) == dict and 'val' in conf[var_name]:
365
                                    val = conf[var_name].get('val')
366
                                    if isinstance(val, dict):
367
                                        val = {str(k): str(v) for k, v in sorted(val.items(), key=lambda x: x[0])}
368
                                    if isinstance(val, list) or isinstance(val, tuple):
369
                                        val = to_bytes(val)
370
                                    var_data = "{0}:{1}\n".format(var_name, val)
371
                                f_var.write(var_data) #.encode(charset))
372
                        f_var.close()
373
                if os.path.isdir(os.path.join(rep_src,src)):
374
                    fics = os.listdir(os.path.join(rep_src,src))
375
                    fics = [(os.path.join(src,fic),os.path.join(dst,fic)) for fic in fics]
376
                else:
377
                    fics = [(src,dst)]
378
                for fic, fic_dst in fics:
379
                    if os.path.isfile(os.path.join(rep_src,fic)):
380
                        if (pattern is None) or fic.endswith(pattern):
381
                            md5res = md5file(os.path.join(rep_src,fic))
382
                            data.append("%s  %s\n" % (md5res, fic_dst))
383
            if Path('/usr/share/zephir/zephir_conf/fichiers_zephir').is_file():
384
                with open('/usr/share/zephir/zephir_conf/fichiers_zephir', 'r') as fz_fh:
385
                    fichiers_zephir = []
386
                    for l in fz_fh.readlines():
387
                        if l.startswith('#'):
388
                            continue
389
                        if l.startswith('%%'):
390
                            break
391
                        fichier_serveur = Path(l.strip())
392
                        if fichier_serveur.is_file():
393
                            md5res = md5file(fichier_serveur.as_posix())
394
                            data.append("{}  {}\n".format(md5res, Path('fichiers_zephir').joinpath(fichier_serveur.name)))
395
                        elif fichier_serveur.is_dir():
396
                            for step in os.walk(fichier_serveur.as_posix()):
397
                                for sub_fichier_serveur in step[2]:
398
                                    sub_fichier_serveur_full_path = Path(step[0]).joinpath(sub_fichier_serveur)
399
                                    md5res = md5file(sub_fichier_serveur_full_path.as_posix())
400
                                    sub_fichier_serveur = sub_fichier_serveur_full_path.relative_to(fichier_serveur)
401
                                    data.append("{}  {}\n".format(md5res,
402
                                                                Path('fichiers_zephir').joinpath(fichier_serveur.name, sub_fichier_serveur)))
403
            if Path('/usr/share/zephir/zephir_conf/fichiers_variante').is_file():
404
                with open('/usr/share/zephir/zephir_conf/fichiers_variante', 'r') as fz_fh:
405
                    fichiers_zephir = []
406
                    for l in fz_fh.readlines():
407
                        if l.startswith('#'):
408
                            continue
409
                        if l.startswith('%%'):
410
                            break
411
                        fichier_serveur = Path(l.strip())
412
                        if fichier_serveur.is_file():
413
                            md5res = md5file(fichier_serveur.as_posix())
414
                            data.append("{}  {}\n".format(md5res, Path('fichiers_zephir').joinpath('variante', fichier_serveur.name)))
415
        except:
416
            # on n'empêche pas de continuer les opérations si le calcul du md5 n'est pas bon
417
            log.msg('!! Erreur rencontrée lors du calcul du md5 de config.eol !!')
418
            traceback.print_exc()
419
        try:
420
            assert conf_zeph.id_serveur != 0
421
            outf = file(os.path.join(self.config['tmp_data_dir'],"config%s.md5" % str(conf_zeph.id_serveur)), "w")
422
        except:
423
            outf = file(os.path.join(self.config['tmp_data_dir'],"config%s.md5" % self.config['host_ref']), "w")
424
        outf.writelines(data)
425
        outf.close()
426

    
427
    def _get_packages(self, *args):
428
        """génère une liste des paquets installés
429
        """
430
        try:
431
            assert conf_zeph.id_serveur != 0
432
            cmd_pkg = ("/usr/bin/dpkg-query -W >" + os.path.join(self.config['tmp_data_dir'],"packages%s.list" % str(conf_zeph.id_serveur)))
433
        except:
434
            cmd_pkg = ("/usr/bin/dpkg-query -W >" + os.path.join(self.config['tmp_data_dir'],"packages%s.list" % self.config['host_ref']))
435
        os.system(cmd_pkg)
436

    
437
    def _make_archive(self,*args):
438
        self._check_md5()
439
        self._get_packages()
440
        # compression des données à envoyer
441
        try:
442
            assert conf_zeph.id_serveur != 0
443
            tarball = os.path.join(self.config['uucp_dir'],'site%s.tar' % str(conf_zeph.id_serveur))
444
        except:
445
            tarball = os.path.join(self.config['uucp_dir'],'site%s.tar' % self.config['host_ref'])
446
        tar_cwd = os.path.dirname(os.path.abspath(self.config['tmp_data_dir']))
447
        tar_dir = os.path.basename(os.path.abspath(self.config['tmp_data_dir']))
448
        res = utils.getProcessOutput('/bin/tar',
449
                                     args = ('czf', tarball,
450
                                             '--exclude', 'private',
451
                                             '-C', tar_cwd,
452
                                             tar_dir))
453
        res.addCallbacks(self._try_chown,
454
                         lambda x: log.msg(_("/!\ archiving failed (%s)\n"
455
                                             "data: %s\narchive: %s")
456
                                           % (str(x), self.config['state_dir'], tarball)),
457
                         callbackArgs = [tarball])
458

    
459
    def _try_chown(self, tar_output, tarball):
460
        try:
461
            uucp_uid, uucp_gid = pwd.getpwnam('uucp')[2:4]
462
            uid = os.getuid()
463
            os.chown(tarball, uucp_uid, uucp_gid) # only change group id so that uucp can read while we can still write
464
        except OSError, e:
465
            log.msg("/!\ chown error, check authorizations (%s)" % e)
466
        # upload uucp
467
        # on fait également un chown sur le fichier deffered_logs au cas ou il serait en root
468
        try:
469
            uucp_uid, uucp_gid = pwd.getpwnam('uucp')[2:4]
470
            os.chown('/usr/share/zephir/deffered_logs', uucp_uid, uucp_gid)
471
        except:
472
            log.msg("/!\ chown error on deffered_logs")
473
        os.system('/usr/share/zephir/scripts/zephir_client call &> /dev/null')
474

    
475

    
476
    # xmlrpc methods
477

    
478
    def xmlrpc_list_agents(self):
479
        """@return: Liste des agents chargés"""
480
        return self.agents.keys()
481
    xmlrpc_list_agents.signature = [['array']]
482

    
483
    def xmlrpc_agents_menu(self):
484
        """@return: Liste des agents chargés et structure d'affichage"""
485
        try:
486
            menu = {}
487
            for name, agent in self.agents.items():
488
                if agent.section != None:
489
                    if not menu.has_key(agent.section):
490
                        menu[agent.section] = []
491
                    menu[agent.section].append((name, agent.description))
492
            return menu
493
        except Exception, e:
494
            log.msg(e)
495
    xmlrpc_agents_menu.signature = [['struct']]
496

    
497
    def xmlrpc_status_for_agents(self, agent_name_list = []):
498
        """
499
        @return: Les statuts des agents listés dans un dictionnaire
500
        C{{nom:status}}. Le status est lui-même un dictionnaire avec
501
        pour clés C{'level'} et C{'message'}. Seuls les noms d'agents
502
        effectivement chargés apparaîtront parmi les clés du
503
        dictionnaire.
504
        """
505
        result = {}
506
        if len(agent_name_list) == 0:
507
            agent_name_list = self.agents.keys()
508
        for agent_name in agent_name_list:
509
            if self.agents.has_key(agent_name):
510
                result[agent_name] = self.agents[agent_name].check_status().to_dict()
511
        return result
512
    xmlrpc_status_for_agents.signature = [['string', 'struct']]
513

    
514
    def xmlrpc_reset_max_status_for_agents(self, agent_name_list=[]):
515
            if len(agent_name_list) == 0:
516
                agent_name_list = self.agents.keys()
517
            for agent_name in agent_name_list:
518
                if self.agents.has_key(agent_name):
519
                    self.agents[agent_name].reset_max_status()
520
            return "ok"
521

    
522
    def xmlrpc_archive_for_upload(self):
523
        self.wakeup_for_upload(False)
524
        return "ok"
525

    
526

    
527
class PublisherService(service.MultiService):
528
    """Serves the web interface for current agent data"""
529

    
530
    def __init__(self, config, parent, root_resource,
531
                 live_agents=None,
532
                 show_clients_page=True):
533
        """config should be complete"""
534
        service.MultiService.__init__(self)
535
        self.config = config
536
        self.show_clients_page = show_clients_page
537
        self.manager = ClientManager(self.config, live_agents)
538
        # attach to parent service
539
        self.setServiceParent(service.IServiceCollection(parent))
540
        # run webserver
541
        rsrc = ZephirServerResource(self.config, self.manager)
542
        root_resource.putChild('agents', rsrc)
543
        default_page = './agents/'
544
        if not self.show_clients_page:
545
            default_page += self.config['host_ref'] + '/'
546
        root_resource.putChild('', util.Redirect(default_page))
547

    
548
#TODO
549
# update resources: loading host structures, manager -> agent dict
550
# connect publisher and updater to zephir service (web server, config...)
551

    
552
# client manager: liste des host_ref, {host_ref => agent_manager}
553
# agent manager: structure, {nom => agent_data}