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

#
# 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
24 25 26
"""Classes and functions for handling configuration and commands

   This module provides the ModuleCCSession and UICCSession classes,
27
   as well as a set of utility functions to create and parse messages
28 29 30 31 32 33 34 35 36 37
   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.
"""
38

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

43
class ModuleCCSessionError(Exception): pass
44 45

def parse_answer(msg):
Jelte Jansen's avatar
Jelte Jansen committed
46 47 48
    """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
49 50
    if type(msg) != dict:
        raise ModuleCCSessionError("Answer message is not a dict: " + str(msg))
51
    if 'result' not in msg:
52
        raise ModuleCCSessionError("answer message does not contain 'result' element")
53
    elif type(msg['result']) != list:
54
        raise ModuleCCSessionError("wrong result type in answer message")
55
    elif len(msg['result']) < 1:
56
        raise ModuleCCSessionError("empty result list in answer message")
57
    elif type(msg['result'][0]) != int:
58
        raise ModuleCCSessionError("wrong rcode type in answer message")
59 60
    else:
        if len(msg['result']) > 1:
Jelte Jansen's avatar
Jelte Jansen committed
61 62
            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")
63 64 65 66 67 68 69 70 71 72
            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:
73
        raise ModuleCCSessionError("rcode in create_answer() must be an integer")
74
    if rcode != 0 and type(arg) != str:
75
        raise ModuleCCSessionError("arg in create_answer for rcode != 0 must be a string describing the error")
76
    if arg != None:
77 78
        return { 'result': [ rcode, arg ] }
    else:
79
        return { 'result': [ rcode ] }
80

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

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 93 94 95 96 97 98

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()
99
        if cmd == "command" and type(value) == list:
Jelte Jansen's avatar
Jelte Jansen committed
100
            if len(value) == 1 and type(value[0]) == str:
101
                return value[0], None
Jelte Jansen's avatar
Jelte Jansen committed
102
            elif len(value) > 1 and type(value[0]) == str:
103
                return value[0], value[1]
104 105 106 107 108 109 110
    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
111 112
    if type(command_name) != str:
        raise ModuleCCSessionError("command in create_command() not a string")
113 114 115 116 117 118
    cmd = [ command_name ]
    if params:
        cmd.append(params)
    msg = { 'command': cmd }
    return msg

119
class ModuleCCSession(ConfigData):
Jelte Jansen's avatar
Jelte Jansen committed
120 121 122
    """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
123
       options, and commands. It also gives the ModuleCCSession two callback
Jelte Jansen's avatar
Jelte Jansen committed
124 125 126
       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
127
       ModuleCCSession"""
Jelte Jansen's avatar
Jelte Jansen committed
128
       
Jelte Jansen's avatar
Jelte Jansen committed
129
    def __init__(self, spec_file_name, config_handler, command_handler, cc_session = None):
130
        """Initialize a ModuleCCSession. This does *NOT* send the
Jelte Jansen's avatar
Jelte Jansen committed
131
           specification and request the configuration yet. Use start()
132
           for that once the ModuleCCSession has been initialized.
Jelte Jansen's avatar
Jelte Jansen committed
133 134 135 136
           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."""
137 138 139 140
        module_spec = isc.config.module_spec_from_file(spec_file_name)
        ConfigData.__init__(self, module_spec)
        
        self._module_name = module_spec.get_module_name()
141
        
142 143
        self.set_config_handler(config_handler)
        self.set_command_handler(command_handler)
144

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

151
    def start(self):
Jelte Jansen's avatar
Jelte Jansen committed
152 153 154
        """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"""
155
        self.__send_spec()
156
        self.__request_config()
157

158
    def get_socket(self):
Jelte Jansen's avatar
Jelte Jansen committed
159 160 161 162
        """Returns the socket from the command channel session. This can
           be used in select() loops to see if there is anything on the
           channel. This is not strictly necessary as long as
           check_command is called periodically."""
163 164
        return self._session._socket
    
165
    def get_session(self):
166
        """Returns the command-channel session that is used, so the
Jelte Jansen's avatar
Jelte Jansen committed
167
           application can use it directly."""
168
        return self._session
169 170

    def close(self):
Jelte Jansen's avatar
Jelte Jansen committed
171
        """Close the session to the command channel"""
172 173
        self._session.close()

174
    def check_command(self):
Jelte Jansen's avatar
Jelte Jansen committed
175 176 177
        """Check whether there is a command or configuration update
           on the channel. Call the corresponding callback function if
           there is."""
178
        msg, env = self._session.group_recvmsg(False)
179
        # should we default to an answer? success-by-default? unhandled error?
180
        if msg and not 'result' in msg:
Jelte Jansen's avatar
Jelte Jansen committed
181 182
            answer = None
            try:
Jelte Jansen's avatar
Jelte Jansen committed
183 184 185
                cmd, arg = isc.config.ccsession.parse_command(msg)
                if cmd == COMMAND_CONFIG_UPDATE:
                    new_config = arg
186 187 188 189 190 191
                    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):
                        answer = create_answer(1, " ".join(errors))
                    else:
Jelte Jansen's avatar
Jelte Jansen committed
192 193
                        answer = self._config_handler(new_config)
                else:
194 195 196 197
                    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
198 199 200 201
            except Exception as exc:
                answer = create_answer(1, str(exc))
            if answer:
                self._session.group_reply(env, answer)
202
    
203
    def set_config_handler(self, config_handler):
204 205
        """Set the config handler for this module. The handler is a
           function that takes the full configuration and handles it.
206
           It should return an answer created with create_answer()"""
207 208 209
        self._config_handler = config_handler
        # should we run this right now since we've changed the handler?

210
    def set_command_handler(self, command_handler):
211 212
        """Set the command handler for this module. The handler is a
           function that takes a command as defined in the .spec file
213
           and return an answer created with create_answer()"""
214 215
        self._command_handler = command_handler

216
    def __send_spec(self):
217
        """Sends the data specification to the configuration manager"""
218 219
        msg = create_command(COMMAND_MODULE_SPEC, self.get_module_spec().get_full_spec())
        self._session.group_sendmsg(msg, "ConfigManager")
220 221
        answer, env = self._session.group_recvmsg(False)
        
222
    def __request_config(self):
Jelte Jansen's avatar
Jelte Jansen committed
223 224
        """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"""
225 226
        self._session.group_sendmsg({ "command": [ "get_config", { "module_name": self._module_name } ] }, "ConfigManager")
        answer, env = self._session.group_recvmsg(False)
Jelte Jansen's avatar
Jelte Jansen committed
227 228 229 230 231 232 233 234 235 236
        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("Error requesting configuration: " + value)
237
        else:
Jelte Jansen's avatar
Jelte Jansen committed
238
            raise ModuleCCSessionError("No answer from configuration manager")
239 240 241 242

class UIModuleCCSession(MultiConfigData):
    """This class is used in a configuration user interface. It contains
       specific functions for getting, displaying, and sending
243
       configuration settings through the b10-cmdctl module."""
244
    def __init__(self, conn):
245 246
        """Initialize a UIModuleCCSession. The conn object that is
           passed must have send_GET and send_POST functions"""
247 248 249 250 251 252
        MultiConfigData.__init__(self)
        self._conn = conn
        self.request_specifications()
        self.request_current_config()

    def request_specifications(self):
253
        """Request the module specifications from b10-cmdctl"""
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268
        # 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)
        specs = self._conn.send_GET('/config_spec')
        commands = self._conn.send_GET('/commands')
        for module in specs.keys():
            cur_spec = { 'module_name': module }
            if module in specs and specs[module]:
                cur_spec['config_data'] = specs[module]
            if module in commands and commands[module]:
                cur_spec['commands'] = commands[module]
            
            self.set_specification(isc.config.ModuleSpec(cur_spec))

    def request_current_config(self):
269 270
        """Requests the current configuration from the configuration
           manager through b10-cmdctl, and stores those as CURRENT"""
271 272
        config = self._conn.send_GET('/config_data')
        if 'version' not in config or config['version'] != 1:
273
            raise ModuleCCSessionError("Bad config version")
274
        self._set_current_config(config)
275 276

    def add_value(self, identifier, value_str):
277 278 279
        """Add a value to a configuration list. Raises a DataTypeError
           if the value does not conform to the list_item_spec field
           of the module config data specification"""
280 281
        module_spec = self.find_spec_part(identifier)
        if (type(module_spec) != dict or "list_item_spec" not in module_spec):
282
            raise isc.cc.data.DataNotFoundError(str(identifier) + " is not a list")
283 284 285 286 287 288 289 290 291
        value = isc.cc.data.parse_value_str(value_str)
        cur_list, status = self.get_value(identifier)
        if not cur_list:
            cur_list = []
        if value not in cur_list:
            cur_list.append(value)
        self.set_value(identifier, cur_list)

    def remove_value(self, identifier, value_str):
292 293 294 295 296
        """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
297
        module_spec = self.find_spec_part(identifier)
298
        if (type(module_spec) != dict or "list_item_spec" not in module_spec):
299
            raise isc.cc.data.DataNotFoundError(str(identifier) + " is not a list")
Jelte Jansen's avatar
Jelte Jansen committed
300 301 302 303 304
        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)
305 306 307 308
        if not cur_list:
            cur_list = []
        if value in cur_list:
            cur_list.remove(value)
Jelte Jansen's avatar
Jelte Jansen committed
309
        self.set_value(identifier, cur_list)
310 311

    def commit(self):
312 313
        """Commit all local changes, send them through b10-cmdctl to
           the configuration manager"""
314
        if self.get_local_changes():
315
            self._conn.send_POST('/ConfigManager/set_config', [ self.get_local_changes() ])
316 317 318
            # todo: check result
            self.request_current_config()
            self.clear_local_changes()