ccsession.py 20.9 KB
Newer Older
1
# Copyright (C) 2009  Internet Systems Consortium.
2
# Copyright (C) 2010  CZ NIC
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#
# 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.

#
# Client-side functionality for configuration and commands
#
# It keeps a cc-channel session with the configuration manager daemon,
# and handles configuration updates and direct commands

# modeled after ccsession.h/cc 'protocol' changes here need to be
# made there as well
25
26
27
"""Classes and functions for handling configuration and commands

   This module provides the ModuleCCSession and UICCSession classes,
28
   as well as a set of utility functions to create and parse messages
29
30
31
32
33
34
35
36
37
38
   related to commands and configuration

   Modules should use the ModuleCCSession class to connect to the
   configuration manager, and receive updates and commands from
   other modules.

   Configuration user interfaces should use the UICCSession to connect
   to b10-cmdctl, and receive and send configuration and commands
   through that to the configuration manager.
"""
39

40
from isc.cc import Session
41
from isc.config.config_data import ConfigData, MultiConfigData, BIND10_CONFIG_DATA_VERSION
42
import isc
43

44
class ModuleCCSessionError(Exception): pass
45
46

def parse_answer(msg):
Jelte Jansen's avatar
Jelte Jansen committed
47
48
49
    """Returns a tuple (rcode, value), where value depends on the
       command that was called. If rcode != 0, value is a string
       containing an error message"""
Jelte Jansen's avatar
Jelte Jansen committed
50
51
    if type(msg) != dict:
        raise ModuleCCSessionError("Answer message is not a dict: " + str(msg))
52
    if 'result' not in msg:
53
        raise ModuleCCSessionError("answer message does not contain 'result' element")
54
    elif type(msg['result']) != list:
55
        raise ModuleCCSessionError("wrong result type in answer message")
56
    elif len(msg['result']) < 1:
57
        raise ModuleCCSessionError("empty result list in answer message")
58
    elif type(msg['result'][0]) != int:
59
        raise ModuleCCSessionError("wrong rcode type in answer message")
60
61
    else:
        if len(msg['result']) > 1:
Jelte Jansen's avatar
Jelte Jansen committed
62
63
            if (msg['result'][0] != 0 and type(msg['result'][1]) != str):
                raise ModuleCCSessionError("rcode in answer message is non-zero, value is not a string")
64
65
66
67
68
69
70
71
72
73
            return msg['result'][0], msg['result'][1]
        else:
            return msg['result'][0], None

def create_answer(rcode, arg = None):
    """Creates an answer packet for config&commands. rcode must be an
       integer. If rcode == 0, arg is an optional value that depends
       on what the command or option was. If rcode != 0, arg must be
       a string containing an error message"""
    if type(rcode) != int:
74
        raise ModuleCCSessionError("rcode in create_answer() must be an integer")
75
    if rcode != 0 and type(arg) != str:
76
        raise ModuleCCSessionError("arg in create_answer for rcode != 0 must be a string describing the error")
77
    if arg != None:
78
79
        return { 'result': [ rcode, arg ] }
    else:
80
        return { 'result': [ rcode ] }
81

82
83
# 'fixed' commands
"""Fixed names for command and configuration messages"""
Jelte Jansen's avatar
Jelte Jansen committed
84
COMMAND_CONFIG_UPDATE = "config_update"
Jelte Jansen's avatar
Jelte Jansen committed
85
COMMAND_MODULE_SPECIFICATION_UPDATE = "module_specification_update"
86
87
88
89
90

COMMAND_GET_COMMANDS_SPEC = "get_commands_spec"
COMMAND_GET_CONFIG = "get_config"
COMMAND_SET_CONFIG = "set_config"
COMMAND_GET_MODULE_SPEC = "get_module_spec"
91
COMMAND_MODULE_SPEC = "module_spec"
92
COMMAND_SHUTDOWN = "shutdown"
93
94
95
96
97
98
99

def parse_command(msg):
    """Parses what may be a command message. If it looks like one,
       the function returns (command, value) where command is a
       string. If it is not, this function returns None, None"""
    if type(msg) == dict and len(msg.items()) == 1:
        cmd, value = msg.popitem()
100
        if cmd == "command" and type(value) == list:
Jelte Jansen's avatar
Jelte Jansen committed
101
            if len(value) == 1 and type(value[0]) == str:
102
                return value[0], None
Jelte Jansen's avatar
Jelte Jansen committed
103
            elif len(value) > 1 and type(value[0]) == str:
104
                return value[0], value[1]
105
106
107
108
109
110
111
    return None, None

def create_command(command_name, params = None):
    """Creates a module command message with the given command name (as
       specified in the module's specification, and an optional params
       object"""
    # TODO: validate_command with spec
Jelte Jansen's avatar
Jelte Jansen committed
112
113
    if type(command_name) != str:
        raise ModuleCCSessionError("command in create_command() not a string")
114
115
116
117
118
119
    cmd = [ command_name ]
    if params:
        cmd.append(params)
    msg = { 'command': cmd }
    return msg

120
class ModuleCCSession(ConfigData):
Jelte Jansen's avatar
Jelte Jansen committed
121
122
123
    """This class maintains a connection to the command channel, as
       well as configuration options for modules. The module provides
       a specification file that contains the module name, configuration
124
       options, and commands. It also gives the ModuleCCSession two callback
Jelte Jansen's avatar
Jelte Jansen committed
125
126
127
       functions, one to call when there is a direct command to the
       module, and one to update the configuration run-time. These
       callbacks are called when 'check_command' is called on the
128
       ModuleCCSession"""
Jelte Jansen's avatar
Jelte Jansen committed
129
       
Jelte Jansen's avatar
Jelte Jansen committed
130
    def __init__(self, spec_file_name, config_handler, command_handler, cc_session = None):
131
        """Initialize a ModuleCCSession. This does *NOT* send the
Jelte Jansen's avatar
Jelte Jansen committed
132
           specification and request the configuration yet. Use start()
133
           for that once the ModuleCCSession has been initialized.
Jelte Jansen's avatar
Jelte Jansen committed
134
135
136
137
           specfile_name is the path to the specification file
           config_handler and command_handler are callback functions,
           see set_config_handler and set_command_handler for more
           information on their signatures."""
138
139
140
141
        module_spec = isc.config.module_spec_from_file(spec_file_name)
        ConfigData.__init__(self, module_spec)
        
        self._module_name = module_spec.get_module_name()
142
        
143
144
        self.set_config_handler(config_handler)
        self.set_command_handler(command_handler)
145

Jelte Jansen's avatar
Jelte Jansen committed
146
147
148
149
        if not cc_session:
            self._session = Session()
        else:
            self._session = cc_session
150
        self._session.group_subscribe(self._module_name, "*")
151

152
153
        self._remote_module_configs = {}

154
155
156
157
158
    def __del__(self):
        self._session.group_unsubscribe(self._module_name, "*")
        for module_name in self._remote_module_configs:
            self._session.group_unsubscribe(module_name)

159
    def start(self):
Jelte Jansen's avatar
Jelte Jansen committed
160
161
162
        """Send the specification for this module to the configuration
           manager, and request the current non-default configuration.
           The config_handler will be called with that configuration"""
163
        self.__send_spec()
164
        self.__request_config()
165

166
    def get_socket(self):
167
168
169
170
171
        """Returns the socket from the command channel session. This
           should *only* be used for select() loops to see if there
           is anything on the channel. If that loop is not completely
           time-critical, it is strongly recommended to only use
           check_command(), and not look at the socket at all."""
172
        return self._session._socket
173

174
    def close(self):
Jelte Jansen's avatar
Jelte Jansen committed
175
        """Close the session to the command channel"""
176
177
        self._session.close()

178
    def check_command(self, nonblock=True):
179
        """Check whether there is a command or configuration update on
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
           the channel. This function does a read on the cc session, and
           returns nothing.
           It calls check_command_without_recvmsg()
           to parse the received message.
           
           If nonblock is True, it just checks if there's a command
           and does nothing if there isn't. If nonblock is False, it
           waits until it arrives. It temporarily sets timeout to infinity,
           because commands may not come in arbitrary long time."""
        timeout_orig = self._session.get_timeout()
        self._session.set_timeout(0)
        try:
            msg, env = self._session.group_recvmsg(nonblock)
        finally:
            self._session.set_timeout(timeout_orig)
195
196
197
198
199
200
201
        self.check_command_without_recvmsg(msg, env)

    def check_command_without_recvmsg(self, msg, env):
        """Parse the given message to see if there is a command or a
           configuration update. Calls the corresponding handler
           functions if present. Responds on the channel if the
           handler returns a message.""" 
202
        # should we default to an answer? success-by-default? unhandled error?
203
        if msg is not None and not 'result' in msg:
Jelte Jansen's avatar
Jelte Jansen committed
204
205
            answer = None
            try:
206
                module_name = env['group']
Jelte Jansen's avatar
Jelte Jansen committed
207
208
209
                cmd, arg = isc.config.ccsession.parse_command(msg)
                if cmd == COMMAND_CONFIG_UPDATE:
                    new_config = arg
210
211
212
213
214
215
216
217
218
                    # If the target channel was not this module
                    # it might be in the remote_module_configs
                    if module_name != self._module_name:
                        if module_name in self._remote_module_configs:
                            # no checking for validity, that's up to the
                            # module itself.
                            newc = self._remote_module_configs[module_name].get_local_config()
                            isc.cc.data.merge(newc, new_config)
                            self._remote_module_configs[module_name].set_local_config(newc)
219
220
                        # For other modules, we're not supposed to answer
                        return
221
222

                    # ok, so apparently this update is for us.
223
224
225
226
                    errors = []
                    if not self._config_handler:
                        answer = create_answer(2, self._module_name + " has no config handler")
                    elif not self.get_module_spec().validate_config(False, new_config, errors):
227
                        answer = create_answer(1, ", ".join(errors))
228
                    else:
Jelte Jansen's avatar
Jelte Jansen committed
229
                        isc.cc.data.remove_identical(new_config, self.get_local_config())
Jelte Jansen's avatar
Jelte Jansen committed
230
                        answer = self._config_handler(new_config)
Jelte Jansen's avatar
Jelte Jansen committed
231
232
233
234
235
                        rcode, val = parse_answer(answer)
                        if rcode == 0:
                            newc = self.get_local_config()
                            isc.cc.data.merge(newc, new_config)
                            self.set_local_config(newc)
Jelte Jansen's avatar
Jelte Jansen committed
236
                else:
237
238
239
240
241
242
                    # ignore commands for 'remote' modules
                    if module_name == self._module_name:
                        if self._command_handler:
                            answer = self._command_handler(cmd, arg)
                        else:
                            answer = create_answer(2, self._module_name + " has no command handler")
Jelte Jansen's avatar
Jelte Jansen committed
243
244
245
246
            except Exception as exc:
                answer = create_answer(1, str(exc))
            if answer:
                self._session.group_reply(env, answer)
247
    
248
    def set_config_handler(self, config_handler):
249
250
        """Set the config handler for this module. The handler is a
           function that takes the full configuration and handles it.
251
           It should return an answer created with create_answer()"""
252
253
254
        self._config_handler = config_handler
        # should we run this right now since we've changed the handler?

255
    def set_command_handler(self, command_handler):
256
257
        """Set the command handler for this module. The handler is a
           function that takes a command as defined in the .spec file
258
           and return an answer created with create_answer()"""
259
260
        self._command_handler = command_handler

261
262
263
264
265
266
267
    def add_remote_config(self, spec_file_name):
        """Gives access to the configuration of a different module.
           These remote module options can at this moment only be
           accessed through get_remote_config_value(). This function
           also subscribes to the channel of the remote module name
           to receive the relevant updates. It is not possible to
           specify your own handler for this right now.
268
269
           start() must have been called on this CCSession
           prior to the call to this method.
270
271
272
273
274
275
276
           Returns the name of the module."""
        module_spec = isc.config.module_spec_from_file(spec_file_name)
        module_cfg = ConfigData(module_spec)
        module_name = module_spec.get_module_name()
        self._session.group_subscribe(module_name);

        # Get the current config for that module now
277
        seq = self._session.group_sendmsg(create_command(COMMAND_GET_CONFIG, { "module_name": module_name }), "ConfigManager")
278
279
280
281
282
283
284

        try:
            answer, env = self._session.group_recvmsg(False, seq)
        except isc.cc.SessionTimeout:
            raise ModuleCCSessionError("No answer from ConfigManager when "
                                       "asking about Remote module " +
                                       module_name)
285
286
287
        if answer:
            rcode, value = parse_answer(answer)
            if rcode == 0:
288
                if value != None and module_spec.validate_config(False, value):
289
290
291
292
293
294
295
296
297
                    module_cfg.set_local_config(value);

        # all done, add it
        self._remote_module_configs[module_name] = module_cfg
        return module_name
        
    def remove_remote_config(self, module_name):
        """Removes the remote configuration access for this module"""
        if module_name in self._remote_module_configs:
298
            self._session.group_unsubscribe(module_name)
299
300
301
302
303
304
305
306
307
308
309
310
            del self._remote_module_configs[module_name]

    def get_remote_config_value(self, module_name, identifier):
        """Returns the current setting for the given identifier at the
           given module. If the module has not been added with
           add_remote_config, a ModuleCCSessionError is raised"""
        if module_name in self._remote_module_configs:
            return self._remote_module_configs[module_name].get_value(identifier)
        else:
            raise ModuleCCSessionError("Remote module " + module_name +
                                       " not found")

311
    def __send_spec(self):
312
        """Sends the data specification to the configuration manager"""
313
        msg = create_command(COMMAND_MODULE_SPEC, self.get_module_spec().get_full_spec())
314
        seq = self._session.group_sendmsg(msg, "ConfigManager")
315
316
317
318
319
        try:
            answer, env = self._session.group_recvmsg(False, seq)
        except isc.cc.SessionTimeout:
            # TODO: log an error?
            pass
320
        
321
    def __request_config(self):
Jelte Jansen's avatar
Jelte Jansen committed
322
323
        """Asks the configuration manager for the current configuration, and call the config handler if set.
           Raises a ModuleCCSessionError if there is no answer from the configuration manager"""
324
        seq = self._session.group_sendmsg(create_command(COMMAND_GET_CONFIG, { "module_name": self._module_name }), "ConfigManager")
325
326
327
328
329
330
331
332
333
334
335
336
        try:
            answer, env = self._session.group_recvmsg(False, seq)
            if answer:
                rcode, value = parse_answer(answer)
                if rcode == 0:
                    if value != None and self.get_module_spec().validate_config(False, value):
                        self.set_local_config(value);
                        if self._config_handler:
                            self._config_handler(value)
                else:
                    # log error
                    print("[" + self._module_name + "] Error requesting configuration: " + value)
Jelte Jansen's avatar
Jelte Jansen committed
337
            else:
338
339
340
                raise ModuleCCSessionError("No answer from configuration manager")
        except isc.cc.SessionTimeout:
            raise ModuleCCSessionError("CC Session timeout waiting for configuration manager")
341

342

343
344
345
class UIModuleCCSession(MultiConfigData):
    """This class is used in a configuration user interface. It contains
       specific functions for getting, displaying, and sending
346
       configuration settings through the b10-cmdctl module."""
347
    def __init__(self, conn):
348
349
        """Initialize a UIModuleCCSession. The conn object that is
           passed must have send_GET and send_POST functions"""
350
351
352
353
354
355
        MultiConfigData.__init__(self)
        self._conn = conn
        self.request_specifications()
        self.request_current_config()

    def request_specifications(self):
356
        """Request the module specifications from b10-cmdctl"""
357
358
359
        # this step should be unnecessary but is the current way cmdctl returns stuff
        # so changes are needed there to make this clean (we need a command to simply get the
        # full specs for everything, including commands etc, not separate gets for that)
Jelte Jansen's avatar
Jelte Jansen committed
360
        specs = self._conn.send_GET('/module_spec')
361
        for module in specs.keys():
Jelte Jansen's avatar
Jelte Jansen committed
362
            self.set_specification(isc.config.ModuleSpec(specs[module]))
363

364
365
366
367
    def update_specs_and_config(self):
        self.request_specifications();
        self.request_current_config();

368
    def request_current_config(self):
369
370
        """Requests the current configuration from the configuration
           manager through b10-cmdctl, and stores those as CURRENT"""
371
        config = self._conn.send_GET('/config_data')
372
        if 'version' not in config or config['version'] != BIND10_CONFIG_DATA_VERSION:
373
            raise ModuleCCSessionError("Bad config version")
374
        self._set_current_config(config)
375

376

377
    def add_value(self, identifier, value_str = None):
378
379
        """Add a value to a configuration list. Raises a DataTypeError
           if the value does not conform to the list_item_spec field
380
381
382
           of the module config data specification. If value_str is
           not given, we add the default as specified by the .spec
           file."""
383
384
        module_spec = self.find_spec_part(identifier)
        if (type(module_spec) != dict or "list_item_spec" not in module_spec):
385
            raise isc.cc.data.DataNotFoundError(str(identifier) + " is not a list")
386

387
388
389
        cur_list, status = self.get_value(identifier)
        if not cur_list:
            cur_list = []
390
391
392
393
394
395
396
397
398
399
400
401

        # Hmm. Do we need to check for duplicates?
        value = None
        if value_str is not None:
            value = isc.cc.data.parse_value_str(value_str)
        else:
            if "item_default" in module_spec["list_item_spec"]:
                value = module_spec["list_item_spec"]["item_default"]

        if value is None:
            raise isc.cc.data.DataNotFoundError("No value given and no default for " + str(identifier))
            
402
403
        if value not in cur_list:
            cur_list.append(value)
404
            self.set_value(identifier, cur_list)
405
406

    def remove_value(self, identifier, value_str):
407
408
409
410
411
        """Remove a value from a configuration list. The value string
           must be a string representation of the full item. Raises
           a DataTypeError if the value at the identifier is not a list,
           or if the given value_str does not match the list_item_spec
           """
Jelte Jansen's avatar
Jelte Jansen committed
412
        module_spec = self.find_spec_part(identifier)
413
        if (type(module_spec) != dict or "list_item_spec" not in module_spec):
414
            raise isc.cc.data.DataNotFoundError(str(identifier) + " is not a list")
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433

        if value_str is None:
            # we are directly removing an list index
            id, list_indices = isc.cc.data.split_identifier_list_indices(identifier)
            if list_indices is None:
                raise DataTypeError("identifier in remove_value() does not contain a list index, and no value to remove")
            else:
                self.set_value(identifier, None)
        else:
            value = isc.cc.data.parse_value_str(value_str)
            isc.config.config_data.check_type(module_spec, [value])
            cur_list, status = self.get_value(identifier)
            #if not cur_list:
            #    cur_list = isc.cc.data.find_no_exc(self.config.data, identifier)
            if not cur_list:
                cur_list = []
            if value in cur_list:
                cur_list.remove(value)
            self.set_value(identifier, cur_list)
434
435

    def commit(self):
436
437
        """Commit all local changes, send them through b10-cmdctl to
           the configuration manager"""
438
        if self.get_local_changes():
439
440
441
442
443
444
445
446
447
448
449
450
451
            response = self._conn.send_POST('/ConfigManager/set_config',
                                            [ self.get_local_changes() ])
            answer = isc.cc.data.parse_value_str(response.read().decode())
            # answer is either an empty dict (on success), or one
            # containing errors
            if answer == {}:
                self.request_current_config()
                self.clear_local_changes()
            elif "error" in answer:
                print("Error: " + answer["error"])
                print("Configuration not committed")
            else:
                raise ModuleCCSessionError("Unknown format of answer in commit(): " + str(answer))