cfgmgr.py 20.5 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Copyright (C) 2010  Internet Systems Consortium.
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND INTERNET SYSTEMS CONSORTIUM
# DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL
# INTERNET SYSTEMS CONSORTIUM BE LIABLE FOR ANY SPECIAL, DIRECT,
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING
# FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

16
17
18
19
20
"""This is the BIND 10 configuration manager, run by b10-cfgmgr.

   It stores the system configuration, and sends updates of the
   configuration to the modules that need them.
"""
21

22
import isc
23
24
25
import signal
import ast
import os
26
import copy
27
import tempfile
28
import json
29
import errno
30
from isc.cc import data
31
from isc.config import ccsession, config_data
32

Jelte Jansen's avatar
Jelte Jansen committed
33
class ConfigManagerDataReadError(Exception):
34
35
    """This exception is thrown when there is an error while reading
       the current configuration on startup."""
Jelte Jansen's avatar
Jelte Jansen committed
36
37
38
    pass

class ConfigManagerDataEmpty(Exception):
39
40
    """This exception is thrown when the currently stored configuration
       is not found, or appears empty."""
Jelte Jansen's avatar
Jelte Jansen committed
41
42
    pass

43
class ConfigManagerData:
44
45
46
    """This class hold the actual configuration information, and
       reads it from and writes it to persistent storage"""

47
    def __init__(self, data_path, file_name):
Jelte Jansen's avatar
Jelte Jansen committed
48
49
50
        """Initialize the data for the configuration manager, and
           set the version and path for the data store. Initializing
           this does not yet read the database, a call to
51
52
53
54
55
           read_from_file is needed for that.

           In case the file_name is absolute, data_path is ignored
           and the directory where the file_name lives is used instead.
           """
56
        self.data = {}
57
        self.data['version'] = config_data.BIND10_CONFIG_DATA_VERSION
58
59
60
61
62
63
        if os.path.isabs(file_name):
            self.db_filename = file_name
            self.data_path = os.path.dirname(file_name)
        else:
            self.db_filename = data_path + os.sep + file_name
            self.data_path = data_path
64

65
    def read_from_file(data_path, file_name):
66
67
68
69
70
71
72
73
74
75
76
        """Read the current configuration found in the file file_name.
           If file_name is absolute, data_path is ignored. Otherwise
           we look for the file_name in data_path directory.

           If the file does not exist, a ConfigManagerDataEmpty exception is
           raised. If there is a parse error, or if the data in the file has
           the wrong version, a ConfigManagerDataReadError is raised. In the
           first case, it is probably safe to log and ignore. In the case of
           the second exception, the best way is probably to report the error
           and stop loading the system.
           """
Jelte Jansen's avatar
Jelte Jansen committed
77
        config = ConfigManagerData(data_path, file_name)
78
        file = None
79
        try:
80
            file = open(config.db_filename, 'r')
81
            file_config = json.loads(file.read())
82
83
84
85
86
87
88
89
90
91
92
93
            # handle different versions here
            # If possible, we automatically convert to the new
            # scheme and update the configuration
            # If not, we raise an exception
            if 'version' in file_config:
                if file_config['version'] == config_data.BIND10_CONFIG_DATA_VERSION:
                    config.data = file_config
                elif file_config['version'] == 1:
                    # only format change, no other changes necessary
                    file_config['version'] = 2
                    print("[b10-cfgmgr] Updating configuration database version from 1 to 2")
                    config.data = file_config
94
                else:
95
                    if config_data.BIND10_CONFIG_DATA_VERSION > file_config['version']:
96
                        raise ConfigManagerDataReadError("Cannot load configuration file: version %d no longer supported" % file_config['version'])
97
                    else:
98
                        raise ConfigManagerDataReadError("Cannot load configuration file: version %d not yet supported" % file_config['version'])
99
            else:
100
                raise ConfigManagerDataReadError("No version information in configuration file " + config.db_filename)
101
        except IOError as ioe:
102
103
104
105
106
107
            # if IOError is 'no such file or directory', then continue
            # (raise empty), otherwise fail (raise error)
            if ioe.errno == errno.ENOENT:
                raise ConfigManagerDataEmpty("No configuration file found")
            else:
                raise ConfigManagerDataReadError("Can't read configuration file: " + str(ioe))
108
        except ValueError:
109
            raise ConfigManagerDataReadError("Configuration file out of date or corrupt, please update or remove " + config.db_filename)
110
111
112
        finally:
            if file:
                file.close();
113
114
        return config
        
Jelte Jansen's avatar
Jelte Jansen committed
115
116
117
118
    def write_to_file(self, output_file_name = None):
        """Writes the current configuration data to a file. If
           output_file_name is not specified, the file used in
           read_from_file is used."""
119
        filename = None
120
        try:
121
122
123
124
125
            file = tempfile.NamedTemporaryFile(mode='w',
                                               prefix="b10-config.db.",
                                               dir=self.data_path,
                                               delete=False)
            filename = file.name
126
            file.write(json.dumps(self.data))
127
128
            file.write("\n")
            file.close()
Jelte Jansen's avatar
Jelte Jansen committed
129
            if output_file_name:
130
                os.rename(filename, output_file_name)
Jelte Jansen's avatar
Jelte Jansen committed
131
            else:
132
                os.rename(filename, self.db_filename)
133
        except IOError as ioe:
Jelte Jansen's avatar
Jelte Jansen committed
134
            # TODO: log this (level critical)
135
            print("[b10-cfgmgr] Unable to write configuration file; configuration not stored: " + str(ioe))
136
            # TODO: debug option to keep file?
Jelte Jansen's avatar
Jelte Jansen committed
137
138
        except OSError as ose:
            # TODO: log this (level critical)
139
            print("[b10-cfgmgr] Unable to write configuration file; configuration not stored: " + str(ose))
140
141
142
143
144
145
        try:
            if filename and os.path.exists(filename):
                os.remove(filename)
        except OSError:
            # Ok if we really can't delete it anymore, leave it
            pass
146

Jelte Jansen's avatar
Jelte Jansen committed
147
148
149
150
151
152
153
    def __eq__(self, other):
        """Returns True if the data contained is equal. data_path and
           db_filename may be different."""
        if type(other) != type(self):
            return False
        return self.data == other.data

154
class ConfigManager:
Jelte Jansen's avatar
Jelte Jansen committed
155
    """Creates a configuration manager. The data_path is the path
156
157
       to the directory containing the configuraton file,
       database_filename points to the configuration file.
Jelte Jansen's avatar
Jelte Jansen committed
158
159
160
161
       If session is set, this will be used as the communication
       channel session. If not, a new session will be created.
       The ability to specify a custom session is for testing purposes
       and should not be needed for normal usage."""
162
    def __init__(self, data_path, database_filename, session=None):
163
164
        """Initialize the configuration manager. The data_path string
           is the path to the directory where the configuration is
165
166
           stored (in <data_path>/<database_filename> or in
           <database_filename>, if it is absolute). The dabase_filename
167
           is the config file to load. Session is an optional
168
           cc-channel session. If this is not given, a new one is
169
           created."""
Jelte Jansen's avatar
Jelte Jansen committed
170
        self.data_path = data_path
171
        self.database_filename = database_filename
Jelte Jansen's avatar
Jelte Jansen committed
172
        self.module_specs = {}
173
174
175
176
        # Virtual modules are the ones which have no process running. The
        # checking of validity is done by functions presented here instead
        # of some other process
        self.virtual_modules = {}
177
        self.config = ConfigManagerData(data_path, database_filename)
Jelte Jansen's avatar
Jelte Jansen committed
178
179
180
181
        if session:
            self.cc = session
        else:
            self.cc = isc.cc.Session()
182
183
184
185
186
        self.cc.group_subscribe("ConfigManager")
        self.cc.group_subscribe("Boss", "ConfigManager")
        self.running = False

    def notify_boss(self):
Jelte Jansen's avatar
Jelte Jansen committed
187
        """Notifies the Boss module that the Config Manager is running"""
188
189
        self.cc.group_sendmsg({"running": "configmanager"}, "Boss")

Jelte Jansen's avatar
Jelte Jansen committed
190
    def set_module_spec(self, spec):
191
        """Adds a ModuleSpec"""
Jelte Jansen's avatar
Jelte Jansen committed
192
        self.module_specs[spec.get_module_name()] = spec
193

194
195
196
197
198
    def set_virtual_module(self, spec, check_func):
        """Adds a virtual module with its spec and checking function."""
        self.module_specs[spec.get_module_name()] = spec
        self.virtual_modules[spec.get_module_name()] = check_func

199
200
    def remove_module_spec(self, module_name):
        """Removes the full ModuleSpec for the given module_name.
201
202
           Also removes the virtual module check function if it
           was present.
203
204
205
           Does nothing if the module was not present."""
        if module_name in self.module_specs:
            del self.module_specs[module_name]
206
207
        if module_name in self.virtual_modules:
            del self.virtual_modules[module_name]
208

Jelte Jansen's avatar
Jelte Jansen committed
209
    def get_module_spec(self, module_name = None):
210
        """Returns the full ModuleSpec for the module with the given
Jelte Jansen's avatar
Jelte Jansen committed
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
           module_name. If no module name is given, a dict will
           be returned with 'name': module_spec values. If the
           module name is given, but does not exist, an empty dict
           is returned"""
        if module_name:
            if module_name in self.module_specs:
                return self.module_specs[module_name]
            else:
                # TODO: log error?
                return {}
        else:
            result = {}
            for module in self.module_specs:
                result[module] = self.module_specs[module].get_full_spec()
            return result
226

Jelte Jansen's avatar
Jelte Jansen committed
227
    def get_config_spec(self, name = None):
228
        """Returns a dict containing 'module_name': config_spec for
229
230
231
232
           all modules. If name is specified, only that module will
           be included"""
        config_data = {}
        if name:
Jelte Jansen's avatar
Jelte Jansen committed
233
            if name in self.module_specs:
Jelte Jansen's avatar
Jelte Jansen committed
234
                config_data[name] = self.module_specs[name].get_config_spec()
235
        else:
Jelte Jansen's avatar
Jelte Jansen committed
236
237
            for module_name in self.module_specs.keys():
                config_data[module_name] = self.module_specs[module_name].get_config_spec()
238
239
        return config_data

Jelte Jansen's avatar
Jelte Jansen committed
240
    def get_commands_spec(self, name = None):
241
        """Returns a dict containing 'module_name': commands_spec for
242
243
244
245
           all modules. If name is specified, only that module will
           be included"""
        commands = {}
        if name:
Jelte Jansen's avatar
Jelte Jansen committed
246
            if name in self.module_specs:
Jelte Jansen's avatar
Jelte Jansen committed
247
                commands[name] = self.module_specs[name].get_commands_spec()
248
        else:
Jelte Jansen's avatar
Jelte Jansen committed
249
250
            for module_name in self.module_specs.keys():
                commands[module_name] = self.module_specs[module_name].get_commands_spec()
251
        return commands
252
253

    def read_config(self):
254
        """Read the current configuration from the file specificied at init()"""
255
        try:
256
257
258
            self.config = ConfigManagerData.read_from_file(self.data_path,
                                                           self.\
                                                           database_filename)
259
260
        except ConfigManagerDataEmpty:
            # ok, just start with an empty config
261
262
            self.config = ConfigManagerData(self.data_path,
                                            self.database_filename)
263
264
        
    def write_config(self):
265
        """Write the current configuration to the file specificied at init()"""
266
267
        self.config.write_to_file()

Jelte Jansen's avatar
Jelte Jansen committed
268
    def _handle_get_module_spec(self, cmd):
269
        """Private function that handles the 'get_module_spec' command"""
Jelte Jansen's avatar
Jelte Jansen committed
270
        answer = {}
271
272
273
274
        if cmd != None:
            if type(cmd) == dict:
                if 'module_name' in cmd and cmd['module_name'] != '':
                    module_name = cmd['module_name']
275
                    answer = ccsession.create_answer(0, self.get_module_spec(module_name))
Jelte Jansen's avatar
Jelte Jansen committed
276
                else:
277
                    answer = ccsession.create_answer(1, "Bad module_name in get_module_spec command")
Jelte Jansen's avatar
Jelte Jansen committed
278
            else:
279
                answer = ccsession.create_answer(1, "Bad get_module_spec command, argument not a dict")
Jelte Jansen's avatar
Jelte Jansen committed
280
        else:
281
            answer = ccsession.create_answer(0, self.get_module_spec())
Jelte Jansen's avatar
Jelte Jansen committed
282
283
        return answer

284
285
286
287
288
289
290
291
292
293
    def _handle_get_config_dict(self, cmd):
        """Private function that handles the 'get_config' command
           where the command has been checked to be a dict"""
        if 'module_name' in cmd and cmd['module_name'] != '':
            module_name = cmd['module_name']
            try:
                return ccsession.create_answer(0, data.find(self.config.data, module_name))
            except data.DataNotFoundError as dnfe:
                # no data is ok, that means we have nothing that
                # deviates from default values
294
                return ccsession.create_answer(0, { 'version': config_data.BIND10_CONFIG_DATA_VERSION })
295
296
297
        else:
            return ccsession.create_answer(1, "Bad module_name in get_config command")

Jelte Jansen's avatar
Jelte Jansen committed
298
    def _handle_get_config(self, cmd):
299
        """Private function that handles the 'get_config' command"""
300
301
        if cmd != None:
            if type(cmd) == dict:
302
                return self._handle_get_config_dict(cmd)
Jelte Jansen's avatar
Jelte Jansen committed
303
            else:
304
305
306
307
                return ccsession.create_answer(1, "Bad get_config command, argument not a dict")
        else:
            return ccsession.create_answer(0, self.config.data)

308
    def _handle_set_config_module(self, module_name, cmd):
309
310
311
312
313
314
        # the answer comes (or does not come) from the relevant module
        # so we need a variable to see if we got it
        answer = None
        # todo: use api (and check the data against the definition?)
        old_data = copy.deepcopy(self.config.data)
        conf_part = data.find_no_exc(self.config.data, module_name)
315
316
        update_cmd = None
        use_part = None
317
        if conf_part:
318
            data.merge(conf_part, cmd)
319
            use_part = conf_part
Jelte Jansen's avatar
Jelte Jansen committed
320
        else:
321
            conf_part = data.set(self.config.data, module_name, {})
322
            data.merge(conf_part[module_name], cmd)
323
324
            use_part = conf_part[module_name]

325
326
327
328
        # The command to send
        update_cmd = ccsession.create_command(ccsession.COMMAND_CONFIG_UPDATE,
                                              use_part)

329
330
331
332
333
334
        if module_name in self.virtual_modules:
            # The module is virtual, so call it to get the answer
            try:
                error = self.virtual_modules[module_name](use_part)
                if error is None:
                    answer = ccsession.create_answer(0)
335
336
337
                    # OK, it is successful, send the notify, but don't wait
                    # for answer
                    seq = self.cc.group_sendmsg(update_cmd, module_name)
338
339
340
341
342
343
344
345
                else:
                    answer = ccsession.create_answer(1, error)
            # Make sure just a validating plugin don't kill the whole manager
            except Exception as excp:
                # Provide answer
                answer = ccsession.create_answer(1, "Exception: " + str(excp))
        else:
            # Real module, send it over the wire to it
346
            # send out changed info and wait for answer
347
            seq = self.cc.group_sendmsg(update_cmd, module_name)
348
            try:
349
                # replace 'our' answer with that of the module
350
351
352
                answer, env = self.cc.group_recvmsg(False, seq)
            except isc.cc.SessionTimeout:
                answer = ccsession.create_answer(1, "Timeout waiting for answer from " + module_name)
353
354
355
356
357
358
        if answer:
            rcode, val = ccsession.parse_answer(answer)
            if rcode == 0:
                self.write_config()
            else:
                self.config.data = old_data
Jelte Jansen's avatar
Jelte Jansen committed
359
360
        return answer

361
362
363
364
    def _handle_set_config_all(self, cmd):
        old_data = copy.deepcopy(self.config.data)
        got_error = False
        err_list = []
365
366
367
368
369
370
        # The format of the command is a dict with module->newconfig
        # sets, so we simply call set_config_module for each of those
        for module in cmd:
            if module != "version":
                answer = self._handle_set_config_module(module, cmd[module])
                if answer == None:
371
                    got_error = True
372
373
374
375
376
377
                    err_list.append("No answer message from " + module)
                else:
                    rcode, val = ccsession.parse_answer(answer)
                    if rcode != 0:
                        got_error = True
                        err_list.append(val)
378
379
380
381
382
383
384
385
        if not got_error:
            self.write_config()
            return ccsession.create_answer(0)
        else:
            # TODO rollback changes that did get through, should we re-send update?
            self.config.data = old_data
            return ccsession.create_answer(1, " ".join(err_list))

Jelte Jansen's avatar
Jelte Jansen committed
386
    def _handle_set_config(self, cmd):
387
        """Private function that handles the 'set_config' command"""
388
        answer = None
389

390
        if cmd == None:
391
            return ccsession.create_answer(1, "Wrong number of arguments")
392
        if len(cmd) == 2:
393
            answer = self._handle_set_config_module(cmd[0], cmd[1])
394
        elif len(cmd) == 1:
395
            answer = self._handle_set_config_all(cmd[0])
Jelte Jansen's avatar
Jelte Jansen committed
396
        else:
397
            answer = ccsession.create_answer(1, "Wrong number of arguments")
398
        if not answer:
399
            answer = ccsession.create_answer(1, "No answer message from " + cmd[0])
400
            
Jelte Jansen's avatar
Jelte Jansen committed
401
402
        return answer

Jelte Jansen's avatar
Jelte Jansen committed
403
    def _handle_module_spec(self, spec):
404
        """Private function that handles the 'module_spec' command"""
Jelte Jansen's avatar
Jelte Jansen committed
405
        # todo: validate? (no direct access to spec as
Jelte Jansen's avatar
Jelte Jansen committed
406
        # todo: use ModuleSpec class
Jelte Jansen's avatar
Jelte Jansen committed
407
408
        # todo: error checking (like keyerrors)
        answer = {}
Jelte Jansen's avatar
Jelte Jansen committed
409
        self.set_module_spec(spec)
410
411
412
        
        # We should make one general 'spec update for module' that
        # passes both specification and commands at once
413
414
        spec_update = ccsession.create_command(ccsession.COMMAND_MODULE_SPECIFICATION_UPDATE,
                                               [ spec.get_module_name(), spec.get_full_spec() ])
Likun Zhang's avatar
   
Likun Zhang committed
415
        self.cc.group_sendmsg(spec_update, "Cmdctl")
416
        return ccsession.create_answer(0)
Jelte Jansen's avatar
Jelte Jansen committed
417

418
    def handle_msg(self, msg):
419
        """Handle a command from the cc channel to the configuration manager"""
420
        answer = {}
421
        cmd, arg = ccsession.parse_command(msg)
422
        if cmd:
423
424
425
            if cmd == ccsession.COMMAND_GET_COMMANDS_SPEC:
                answer = ccsession.create_answer(0, self.get_commands_spec())
            elif cmd == ccsession.COMMAND_GET_MODULE_SPEC:
426
                answer = self._handle_get_module_spec(arg)
427
            elif cmd == ccsession.COMMAND_GET_CONFIG:
428
                answer = self._handle_get_config(arg)
429
            elif cmd == ccsession.COMMAND_SET_CONFIG:
430
                answer = self._handle_set_config(arg)
431
            elif cmd == ccsession.COMMAND_SHUTDOWN:
Jelte Jansen's avatar
Jelte Jansen committed
432
                # TODO: logging
433
                #print("[b10-cfgmgr] Received shutdown command")
434
                self.running = False
435
436
                answer = ccsession.create_answer(0)
            elif cmd == ccsession.COMMAND_MODULE_SPEC:
437
438
439
                try:
                    answer = self._handle_module_spec(isc.config.ModuleSpec(arg))
                except isc.config.ModuleSpecError as dde:
440
                    answer = ccsession.create_answer(1, "Error in data definition: " + str(dde))
441
            else:
442
                answer = ccsession.create_answer(1, "Unknown command: " + str(cmd))
443
        else:
444
            answer = ccsession.create_answer(1, "Unknown message format: " + str(msg))
445
446
447
        return answer
        
    def run(self):
448
        """Runs the configuration manager."""
449
450
        self.running = True
        while (self.running):
451
            # we just wait eternally for any command here, so disable
452
            # timeouts for this specific recv
453
            self.cc.set_timeout(0)
454
            msg, env = self.cc.group_recvmsg(False)
455
456
457
            # and set it back to whatever we default to
            self.cc.set_timeout(isc.cc.Session.MSGQ_DEFAULT_TIMEOUT)
            # ignore 'None' value (even though they should not occur)
458
459
460
            # and messages that are answers to questions we did
            # not ask
            if msg is not None and not 'result' in msg:
461
462
                answer = self.handle_msg(msg);
                self.cc.group_reply(env, answer)