~ahasenack/landscape-client/hardy-updates

« back to all changes in this revision

Viewing changes to landscape/watchdog.py

  • Committer: Andreas Hasenack
  • Date: 2011-05-05 14:12:15 UTC
  • Revision ID: andreas@canonical.com-20110505141215-5ymuyyh5es9pwa6p
Added hardy files.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
"""See L{WatchDog}.
 
2
 
 
3
The WatchDog must run as root, because it spawns the Landscape Manager.
 
4
 
 
5
The main C{landscape-client} program uses this watchdog.
 
6
"""
 
7
 
 
8
import os
 
9
import errno
 
10
import sys
 
11
import pwd
 
12
import signal
 
13
import time
 
14
 
 
15
from logging import warning, info, error
 
16
 
 
17
from twisted.internet import reactor
 
18
from twisted.internet.defer import Deferred, succeed
 
19
from twisted.internet.protocol import ProcessProtocol
 
20
from twisted.internet.error import ProcessExitedAlready
 
21
from twisted.application.service import Service, Application
 
22
from twisted.application.app import startApplication
 
23
 
 
24
from landscape.deployment import Configuration, init_logging
 
25
from landscape.lib.twisted_util import gather_results
 
26
from landscape.lib.log import log_failure
 
27
from landscape.lib.bootstrap import (BootstrapList, BootstrapFile,
 
28
                                     BootstrapDirectory)
 
29
from landscape.log import rotate_logs
 
30
from landscape.broker.amp import (
 
31
    RemoteBrokerConnector, RemoteMonitorConnector, RemoteManagerConnector)
 
32
from landscape.reactor import TwistedReactor
 
33
 
 
34
GRACEFUL_WAIT_PERIOD = 10
 
35
MAXIMUM_CONSECUTIVE_RESTARTS = 5
 
36
RESTART_BURST_DELAY = 30 # seconds
 
37
SIGKILL_DELAY = 10
 
38
 
 
39
 
 
40
class DaemonError(Exception):
 
41
    """One of the daemons could not be started."""
 
42
 
 
43
 
 
44
class TimeoutError(Exception):
 
45
    """Something took too long."""
 
46
 
 
47
 
 
48
class ExecutableNotFoundError(Exception):
 
49
    """An executable was not found."""
 
50
 
 
51
 
 
52
class Daemon(object):
 
53
    """A Landscape daemon which can be started and tracked.
 
54
 
 
55
    This class should be subclassed to specify individual daemon.
 
56
 
 
57
    @cvar program: The name of the executable program that will start this
 
58
        daemon.
 
59
    @cvar username: The name of the user to switch to, by default.
 
60
    @cvar service: The DBus service name that the program will be expected to
 
61
        listen on.
 
62
    @cvar max_retries: The maximum number of retries before giving up when
 
63
        trying to connect to the watched daemon.
 
64
    @cvar factor: The factor by which the delay between subsequent connection
 
65
        attempts will increase.
 
66
    """
 
67
 
 
68
    username = "landscape"
 
69
    max_retries = 3
 
70
    factor = 1.1
 
71
 
 
72
    def __init__(self, connector, reactor=reactor, verbose=False,
 
73
                 config=None):
 
74
        """
 
75
        @param connector: The L{RemoteComponentConnector} of the daemon.
 
76
        @param reactor: The reactor used to spawn the process and schedule
 
77
            timed calls.
 
78
        @param verbose: Optionally, report more information when
 
79
            running this program.  Defaults to False.
 
80
        """
 
81
        self._connector = connector
 
82
        self._reactor = reactor
 
83
        self._env = os.environ.copy()
 
84
        if os.getuid() == 0:
 
85
            pwd_info = pwd.getpwnam(self.username)
 
86
            self._uid = pwd_info.pw_uid
 
87
            self._gid = pwd_info.pw_gid
 
88
            self._env["HOME"] = pwd_info.pw_dir
 
89
            self._env["USER"] = self.username
 
90
            self._env["LOGNAME"] = self.username
 
91
        else:
 
92
            # We can only switch UIDs if we're root, so simply don't switch
 
93
            # UIDs if we're not.
 
94
            self._uid = None
 
95
            self._gid = None
 
96
        self._verbose = verbose
 
97
        self._config = config
 
98
        self._process = None
 
99
        self._last_started = 0
 
100
        self._quick_starts = 0
 
101
        self._allow_restart = True
 
102
 
 
103
    def find_executable(self):
 
104
        """Find the fully-qualified path to the executable.
 
105
 
 
106
        If the executable can't be found, L{ExecutableNotFoundError} will be
 
107
        raised.
 
108
        """
 
109
        dirname = os.path.dirname(os.path.abspath(sys.argv[0]))
 
110
        executable = os.path.join(dirname, self.program)
 
111
        if not os.path.exists(executable):
 
112
            raise ExecutableNotFoundError("%s doesn't exist" % (executable,))
 
113
        return executable
 
114
 
 
115
    def start(self):
 
116
        """Start this daemon."""
 
117
        self._process = None
 
118
 
 
119
        now = time.time()
 
120
        if self._last_started + RESTART_BURST_DELAY > now:
 
121
            self._quick_starts += 1
 
122
            if self._quick_starts == MAXIMUM_CONSECUTIVE_RESTARTS:
 
123
                error("Can't keep %s running. Exiting." % self.program)
 
124
                self._reactor.stop()
 
125
                return
 
126
        else:
 
127
            self._quick_starts = 0
 
128
 
 
129
        self._last_started = now
 
130
 
 
131
        self._process = WatchedProcessProtocol(self)
 
132
        exe = self.find_executable()
 
133
        args = [exe, "--ignore-sigint"]
 
134
        if not self._verbose:
 
135
            args.append("--quiet")
 
136
        if self._config:
 
137
            args.extend(["-c", self._config])
 
138
        self._reactor.spawnProcess(self._process, exe, args=args,
 
139
                                   env=self._env, uid=self._uid, gid=self._gid)
 
140
 
 
141
    def stop(self):
 
142
        """Stop this daemon."""
 
143
        if not self._process:
 
144
            return succeed(None)
 
145
        return self._process.kill()
 
146
 
 
147
    def _connect_and_call(self, name, *args, **kwargs):
 
148
        """Connect to the remote daemon over AMP and perform the given command.
 
149
 
 
150
        @param name: The name of the command to perform.
 
151
        @param args: Arguments list to be passed to the connect method
 
152
        @param kwargs: Keywords arguments to pass to the connect method.
 
153
        @return: A L{Deferred} resulting in C{True} if the command was
 
154
            successful or C{False} otherwise.
 
155
        @see: L{RemoteLandscapeComponentCreator.connect}.
 
156
        """
 
157
 
 
158
        def disconnect(ignored):
 
159
            self._connector.disconnect()
 
160
            return True
 
161
 
 
162
        connected = self._connector.connect(self.max_retries, self.factor,
 
163
                                            quiet=True)
 
164
        connected.addCallback(lambda remote: getattr(remote, name)())
 
165
        connected.addCallback(disconnect)
 
166
        connected.addErrback(lambda x: False)
 
167
        return connected
 
168
 
 
169
    def request_exit(self):
 
170
        return self._connect_and_call("exit")
 
171
 
 
172
    def is_running(self):
 
173
        # FIXME Error cases may not be handled in the best possible way
 
174
        # here. We're basically return False if any error happens from the
 
175
        # dbus ping.
 
176
        return self._connect_and_call("ping")
 
177
 
 
178
    def wait(self):
 
179
        """
 
180
        Return a Deferred which will fire when the process has died.
 
181
        """
 
182
        if not self._process:
 
183
            return succeed(None)
 
184
        return self._process.wait()
 
185
 
 
186
    def wait_or_die(self):
 
187
        """
 
188
        Wait for the process to die for C{GRACEFUL_WAIT_PERIOD}. If it hasn't
 
189
        died by that point, send it a SIGTERM. If it doesn't die for
 
190
        C{SIGKILL_DELAY},
 
191
        """
 
192
        if not self._process:
 
193
            return succeed(None)
 
194
        return self._process.wait_or_die()
 
195
 
 
196
    def prepare_for_shutdown(self):
 
197
        """Called by the watchdog when starting to shut us down.
 
198
 
 
199
        It will prevent our L{WatchedProcessProtocol} to restart the process
 
200
        when it exits.
 
201
        """
 
202
        self._allow_restart = False
 
203
 
 
204
    def allow_restart(self):
 
205
        """Return a boolean indicating if the daemon should be restarted."""
 
206
        return self._allow_restart
 
207
 
 
208
    def rotate_logs(self):
 
209
        self._process.rotate_logs()
 
210
 
 
211
 
 
212
class Broker(Daemon):
 
213
    program = "landscape-broker"
 
214
 
 
215
 
 
216
class Monitor(Daemon):
 
217
    program = "landscape-monitor"
 
218
 
 
219
 
 
220
class Manager(Daemon):
 
221
    program = "landscape-manager"
 
222
    username = "root"
 
223
 
 
224
 
 
225
class WatchedProcessProtocol(ProcessProtocol):
 
226
    """
 
227
    A process-watching protocol which sends any of its output to the log file
 
228
    and restarts it when it dies.
 
229
    """
 
230
 
 
231
    _killed = False
 
232
 
 
233
    def __init__(self, daemon):
 
234
        self.daemon = daemon
 
235
        self._wait_result = None
 
236
        self._delayed_really_kill = None
 
237
        self._delayed_terminate = None
 
238
 
 
239
    def kill(self):
 
240
        self._terminate()
 
241
        return self.wait()
 
242
 
 
243
    def _terminate(self, warn=False):
 
244
        if self.transport is not None:
 
245
            if warn:
 
246
                warning("%s didn't exit. Sending SIGTERM"
 
247
                        % (self.daemon.program,))
 
248
            try:
 
249
                self.transport.signalProcess(signal.SIGTERM)
 
250
            except ProcessExitedAlready:
 
251
                pass
 
252
            else:
 
253
                # Give some time for the process, and then show who's the boss.
 
254
                delayed = reactor.callLater(SIGKILL_DELAY, self._really_kill)
 
255
                self._delayed_really_kill = delayed
 
256
 
 
257
    def _really_kill(self):
 
258
        try:
 
259
            self.transport.signalProcess(signal.SIGKILL)
 
260
        except ProcessExitedAlready:
 
261
            pass
 
262
        else:
 
263
            warning("%s didn't die.  Sending SIGKILL." % self.daemon.program)
 
264
        self._delayed_really_kill = None
 
265
 
 
266
    def rotate_logs(self):
 
267
        if self.transport is not None:
 
268
            try:
 
269
                self.transport.signalProcess(signal.SIGUSR1)
 
270
            except ProcessExitedAlready:
 
271
                pass
 
272
 
 
273
    def wait(self):
 
274
        if self.transport.pid is None:
 
275
            return succeed(None)
 
276
        self._wait_result = Deferred()
 
277
        return self._wait_result
 
278
 
 
279
    def wait_or_die(self):
 
280
        self._delayed_terminate = reactor.callLater(GRACEFUL_WAIT_PERIOD,
 
281
                                                    self._terminate, warn=True)
 
282
        return self.wait()
 
283
 
 
284
    def outReceived(self, data):
 
285
        # it's *probably* going to always be line buffered, by accident
 
286
        sys.stdout.write(data)
 
287
 
 
288
    def errReceived(self, data):
 
289
        sys.stderr.write(data)
 
290
 
 
291
    def processEnded(self, reason):
 
292
        """The process has ended; restart it."""
 
293
        if self._delayed_really_kill is not None:
 
294
            self._delayed_really_kill.cancel()
 
295
        if (self._delayed_terminate is not None
 
296
            and self._delayed_terminate.active()):
 
297
            self._delayed_terminate.cancel()
 
298
        if self._wait_result is not None:
 
299
            self._wait_result.callback(None)
 
300
        elif self.daemon.allow_restart():
 
301
            self.daemon.start()
 
302
 
 
303
 
 
304
class WatchDog(object):
 
305
    """
 
306
    The Landscape WatchDog starts all other landscape daemons and ensures that
 
307
    they are working.
 
308
    """
 
309
 
 
310
    def __init__(self, reactor=reactor, verbose=False, config=None,
 
311
                 broker=None, monitor=None, manager=None,
 
312
                 enabled_daemons=None):
 
313
        twisted_reactor = TwistedReactor()
 
314
        if enabled_daemons is None:
 
315
            enabled_daemons = [Broker, Monitor, Manager]
 
316
        if broker is None and Broker in enabled_daemons:
 
317
            broker = Broker(
 
318
                RemoteBrokerConnector(twisted_reactor, config),
 
319
                verbose=verbose, config=config.config)
 
320
        if monitor is None and Monitor in enabled_daemons:
 
321
            monitor = Monitor(
 
322
                RemoteMonitorConnector(twisted_reactor, config),
 
323
                verbose=verbose, config=config.config)
 
324
        if manager is None and Manager in enabled_daemons:
 
325
            manager = Manager(
 
326
                RemoteManagerConnector(twisted_reactor, config),
 
327
                verbose=verbose, config=config.config)
 
328
 
 
329
        self.broker = broker
 
330
        self.monitor = monitor
 
331
        self.manager = manager
 
332
        self.daemons = filter(None, [self.broker, self.monitor, self.manager])
 
333
        self.reactor = reactor
 
334
        self._checking = None
 
335
        self._stopping = False
 
336
        signal.signal(signal.SIGUSR1, self._notify_rotate_logs)
 
337
 
 
338
        self._ping_failures = {}
 
339
 
 
340
    def check_running(self):
 
341
        """Return a list of any daemons that are already running."""
 
342
        results = []
 
343
        for daemon in self.daemons:
 
344
            # This method is called on startup, we basically try to connect
 
345
            # a few times in fast sequence (with exponential backoff), if we
 
346
            # don't get a response we assume the daemon is not running.
 
347
            result = daemon.is_running()
 
348
            result.addCallback(lambda is_running, d=daemon: (is_running, d))
 
349
            results.append(result)
 
350
 
 
351
        def got_all_results(r):
 
352
            return [x[1] for x in r if x[0]]
 
353
        return gather_results(results).addCallback(got_all_results)
 
354
 
 
355
    def start(self):
 
356
        """
 
357
        Start all daemons. The broker will be started first, and no other
 
358
        daemons will be started before it is running and responding to DBUS
 
359
        messages.
 
360
 
 
361
        @return: A deferred which fires when all services have successfully
 
362
            started. If a daemon could not be started, the deferred will fail
 
363
            with L{DaemonError}.
 
364
        """
 
365
        for daemon in self.daemons:
 
366
            daemon.start()
 
367
        self.start_monitoring()
 
368
 
 
369
    def start_monitoring(self):
 
370
        """Start monitoring processes which have already been started."""
 
371
        # Must wait before daemons actually start, otherwise check will
 
372
        # restart them *again*.
 
373
        self._checking = self.reactor.callLater(5, self._check)
 
374
 
 
375
    def _restart_if_not_running(self, is_running, daemon):
 
376
        if (not is_running) and (not self._stopping):
 
377
            warning("%s failed to respond to a ping."
 
378
                    % (daemon.program,))
 
379
            if daemon not in self._ping_failures:
 
380
                self._ping_failures[daemon] = 0
 
381
            self._ping_failures[daemon] += 1
 
382
            if self._ping_failures[daemon] == 5:
 
383
                warning("%s died! Restarting." % (daemon.program,))
 
384
                stopping = daemon.stop()
 
385
 
 
386
                def stopped(ignored):
 
387
                    daemon.start()
 
388
                    self._ping_failures[daemon] = 0
 
389
                stopping.addBoth(stopped)
 
390
                return stopping
 
391
        else:
 
392
            self._ping_failures[daemon] = 0
 
393
 
 
394
    def _check(self):
 
395
        all_running = []
 
396
        for daemon in self.daemons:
 
397
            is_running = daemon.is_running()
 
398
            is_running.addCallback(self._restart_if_not_running, daemon)
 
399
            all_running.append(is_running)
 
400
 
 
401
        def reschedule(ignored):
 
402
            self._checking = self.reactor.callLater(5, self._check)
 
403
        gather_results(all_running).addBoth(reschedule)
 
404
 
 
405
    def request_exit(self):
 
406
        if self._checking is not None and self._checking.active():
 
407
            self._checking.cancel()
 
408
        # Set a flag so that the pinger will avoid restarting the daemons if a
 
409
        # ping has already been sent but not yet responded to.
 
410
        self._stopping = True
 
411
 
 
412
        # This tells the daemons to not automatically restart when they end
 
413
        for daemon in self.daemons:
 
414
            daemon.prepare_for_shutdown()
 
415
 
 
416
        def terminate_processes(broker_stopped):
 
417
            if broker_stopped:
 
418
                results = [daemon.wait_or_die() for daemon in self.daemons]
 
419
            else:
 
420
                # If request_exit fails, we should just kill the daemons
 
421
                # immediately.
 
422
                error("Couldn't request that broker gracefully shut down; "
 
423
                      "killing forcefully.")
 
424
                results = [x.stop() for x in self.daemons]
 
425
            return gather_results(results)
 
426
 
 
427
        result = self.broker.request_exit()
 
428
        return result.addCallback(terminate_processes)
 
429
 
 
430
    def _notify_rotate_logs(self, signal, frame):
 
431
        for daemon in self.daemons:
 
432
            daemon.rotate_logs()
 
433
        rotate_logs()
 
434
 
 
435
 
 
436
class WatchDogConfiguration(Configuration):
 
437
 
 
438
    def make_parser(self):
 
439
        parser = super(WatchDogConfiguration, self).make_parser()
 
440
        parser.add_option("--daemon", action="store_true",
 
441
                          help="Fork and run in the background.")
 
442
        parser.add_option("--pid-file", type="str",
 
443
                          help="The file to write the PID to.")
 
444
        parser.add_option("--monitor-only", action="store_true",
 
445
                          help="Don't enable management features. This is "
 
446
                          "useful if you want to run the client as a non-root "
 
447
                          "user.")
 
448
        return parser
 
449
 
 
450
    def get_enabled_daemons(self):
 
451
        daemons = [Broker, Monitor]
 
452
        if not self.monitor_only:
 
453
            daemons.append(Manager)
 
454
        return daemons
 
455
 
 
456
 
 
457
def daemonize():
 
458
    # See http://www.steve.org.uk/Reference/Unix/faq_2.html#SEC16
 
459
    if os.fork():   # launch child and...
 
460
        os._exit(0) # kill off parent
 
461
    os.setsid()
 
462
    if os.fork():   # launch child and...
 
463
        os._exit(0) # kill off parent again.
 
464
    # some argue that this umask should be 0, but that's annoying.
 
465
    os.umask(077)
 
466
    null = os.open('/dev/null', os.O_RDWR)
 
467
    for i in range(3):
 
468
        try:
 
469
            os.dup2(null, i)
 
470
        except OSError, e:
 
471
            if e.errno != errno.EBADF:
 
472
                raise
 
473
    os.close(null)
 
474
 
 
475
 
 
476
class WatchDogService(Service):
 
477
 
 
478
    def __init__(self, config):
 
479
        self._config = config
 
480
        self.watchdog = WatchDog(verbose=not config.daemon,
 
481
                                 config=config,
 
482
                                 enabled_daemons=config.get_enabled_daemons())
 
483
        self.exit_code = 0
 
484
 
 
485
    def startService(self):
 
486
        Service.startService(self)
 
487
        bootstrap_list.bootstrap(data_path=self._config.data_path,
 
488
                                 log_dir=self._config.log_dir)
 
489
        result = self.watchdog.check_running()
 
490
 
 
491
        def start_if_not_running(running_daemons):
 
492
            if running_daemons:
 
493
                error("ERROR: The following daemons are already running: %s"
 
494
                      % (", ".join(x.program for x in running_daemons)))
 
495
                self.exit_code = 1
 
496
                reactor.crash() # so stopService isn't called.
 
497
                return
 
498
            self._daemonize()
 
499
            info("Watchdog watching for daemons.")
 
500
            return self.watchdog.start()
 
501
 
 
502
        def die(failure):
 
503
            log_failure(failure, "Unknown error occurred!")
 
504
            self.exit_code = 2
 
505
            reactor.crash()
 
506
        result.addCallback(start_if_not_running)
 
507
        result.addErrback(die)
 
508
        return result
 
509
 
 
510
    def _daemonize(self):
 
511
        if self._config.daemon:
 
512
            daemonize()
 
513
            if self._config.pid_file:
 
514
                stream = open(self._config.pid_file, "w")
 
515
                stream.write(str(os.getpid()))
 
516
                stream.close()
 
517
 
 
518
    def stopService(self):
 
519
        info("Stopping client...")
 
520
        Service.stopService(self)
 
521
 
 
522
        # If CTRL-C is pressed twice in a row, the second SIGINT actually
 
523
        # kills us before subprocesses die, and that makes them hang around.
 
524
        signal.signal(signal.SIGINT, signal.SIG_IGN)
 
525
 
 
526
        done = self.watchdog.request_exit()
 
527
        done.addBoth(lambda r: self._remove_pid())
 
528
        return done
 
529
 
 
530
    def _remove_pid(self):
 
531
        pid_file = self._config.pid_file
 
532
        if pid_file is not None and os.access(pid_file, os.W_OK):
 
533
            stream = open(pid_file)
 
534
            pid = stream.read()
 
535
            stream.close()
 
536
            if pid == str(os.getpid()):
 
537
                os.unlink(pid_file)
 
538
 
 
539
 
 
540
bootstrap_list = BootstrapList([
 
541
    BootstrapDirectory("$data_path", "landscape", "root", 0755),
 
542
    BootstrapDirectory("$data_path/package", "landscape", "root", 0755),
 
543
    BootstrapDirectory(
 
544
        "$data_path/package/hash-id", "landscape", "root", 0755),
 
545
    BootstrapDirectory(
 
546
        "$data_path/package/binaries", "landscape", "root", 0755),
 
547
    BootstrapDirectory(
 
548
        "$data_path/package/upgrade-tool", "landscape", "root", 0755),
 
549
    BootstrapDirectory("$data_path/messages", "landscape", "root", 0755),
 
550
    BootstrapDirectory("$data_path/sockets", "landscape", "root", 0750),
 
551
    BootstrapDirectory(
 
552
        "$data_path/custom-graph-scripts", "landscape", "root", 0755),
 
553
    BootstrapDirectory("$log_dir", "landscape", "root", 0755),
 
554
    BootstrapFile("$data_path/package/database", "landscape", "root", 0644),
 
555
    ])
 
556
 
 
557
 
 
558
def clean_environment():
 
559
    """Unset dangerous environment variables.
 
560
 
 
561
    In particular unset all variables beginning with DEBIAN_ or DEBCONF_,
 
562
    to avoid any problems when landscape-client is invoked from its
 
563
    postinst script.  Some environment variables may be set which would affect
 
564
    *other* maintainer scripts which landscape-client invokes (via smart).
 
565
    """
 
566
    for key in os.environ.keys():
 
567
        if (key.startswith("DEBIAN_")
 
568
            or key.startswith("DEBCONF_")
 
569
            or key in ["LANDSCAPE_ATTACHMENTS", "MAIL"]):
 
570
            del os.environ[key]
 
571
 
 
572
 
 
573
def run(args=sys.argv, reactor=None):
 
574
    """Start the watchdog.
 
575
 
 
576
    This is the topmost function that kicks off the Landscape client.  It
 
577
    cleans up the environment, loads the configuration, and starts the
 
578
    reactor.
 
579
 
 
580
    @param args: Command line arguments, including the program name as the
 
581
        first element.
 
582
    @param reactor: The reactor to use.  If none is specified, the global
 
583
        reactor is used.
 
584
    @raise SystemExit: if command line arguments are bad, or when landscape-
 
585
        client is not running as 'root' or 'landscape'.
 
586
    """
 
587
    clean_environment()
 
588
 
 
589
    config = WatchDogConfiguration()
 
590
    config.load(args)
 
591
 
 
592
    try:
 
593
        landscape_uid = pwd.getpwnam("landscape").pw_uid
 
594
    except KeyError:
 
595
        sys.exit("The 'landscape' user doesn't exist!")
 
596
 
 
597
    if os.getuid() not in (0, landscape_uid):
 
598
        sys.exit("landscape-client can only be run as 'root' or 'landscape'.")
 
599
 
 
600
    init_logging(config, "watchdog")
 
601
 
 
602
    application = Application("landscape-client")
 
603
    watchdog_service = WatchDogService(config)
 
604
    watchdog_service.setServiceParent(application)
 
605
 
 
606
    if reactor is None:
 
607
        from twisted.internet import reactor
 
608
    # We add a small delay to work around a Twisted bug: this method should
 
609
    # only be called when the reactor is running, but we still get a
 
610
    # PotentialZombieWarning.
 
611
    reactor.callLater(0, startApplication, application, False)
 
612
    reactor.run()
 
613
    return watchdog_service.exit_code