openrvdas.server.sqlite_server_api
API implementation for interacting with a SQLite3 data store, by Kevin Pedigo, based on in_memory_server_api.py
See api_tool.py for a simple script that exercises this class. See also server/server_api.py for full documentation of the API.
1#!/usr/bin/env python3 2"""API implementation for interacting with a SQLite3 data store, 3 by Kevin Pedigo, based on in_memory_server_api.py 4 5 See api_tool.py for a simple script that exercises this class. 6 See also server/server_api.py for full documentation of the API. 7""" 8 9import logging 10import pprint 11import sys 12import sqlite3 13import os 14import yaml 15import gzip 16from datetime import datetime 17 18 19from server.server_api import ServerAPI # noqa: E402 20 21DEFAULT_MAX_TRIES = 3 22ID_SEPARATOR = ':' 23 24# Not a runtime option because the API doesn't have any 25DATABASE_BACKUPS = True 26DATABASE_COMPRESS = True 27 28# Default location of SQLite database to use/create 29DIR_PATH = os.path.dirname(__file__) 30DEFAULT_DATABASE_PATH = os.path.join(DIR_PATH, 'openrvdas.sql') 31 32# Effectively "time zero" for POSIX systems. 33EPOCH_TIME_ZERO = datetime(1970, 1, 1, 0, 0, 0) 34 35######################################################################## 36# Let's trust SQLite and forget about thread locking. 37# https://www.sqlite.org/lockingv3.html 38# https://www.sqlite.org/threadsafe.html 39# SQLITE3, by default, when built from source, builds in serialized mode 40# (can be safely used by multiple threads without restriction) 41# All distros tested are good. To test yours: 42# strings `which sqlite3` | grep THREADSAFE (should = 1) 43 44 45class SQLiteServerAPI(ServerAPI): 46 ############################ 47 def __init__(self, database_path=DEFAULT_DATABASE_PATH, 48 no_create_database=False): 49 """ 50 database_path - If specified, the path to the SQLite database to use 51 52 no_create_database - If True, and database does not exist, throw an error 53 rather than creating a new database. 54 """ 55 super().__init__() 56 57 # Where do we l 58 self.database_path = database_path 59 self.no_create_database = no_create_database 60 61 self.config = {} 62 self.callbacks = [] 63 self.status = [] 64 self.server_messages = [] 65 self.cx = None 66 self.timestamp = self._get_database_timestamp() 67 68 def _database_exists(self): 69 """Return True if SQLite database at self.database_path exists, 70 and can be read without errors.""" 71 read_only_database_path = ''.join(['file:', self.database_path, '?mode=ro']) 72 try: 73 cx = sqlite3.connect(read_only_database_path, uri=True) # noqa: F841 74 except sqlite3.OperationalError: 75 return False 76 except sqlite3.Error as err: 77 # Some other error 78 logging.error(f'Unknown SQLite database error on read: {err}') 79 return False 80 return True 81 82 def _create_database(self): 83 """Try to create the database.""" 84 logging.debug(f'Creating SQLite database "{self.database_path}"') 85 cx = sqlite3.connect(self.database_path) 86 cu = cx.cursor() 87 cu.execute('CREATE TABLE Cruise (highlander integer primary key not null, config blob, [loaded_time] datetime, compressed integer)') # noqa E501 88 cu.execute('CREATE TABLE lastupdate (highlander integer primary key not null, [timestamp] datetime)') # noqa E501 89 cu.execute('CREATE TABLE logmessages (timestamp datetime primary key not null, loglevel integer, cruise text, source text, user text, message text)') # noqa E501 90 91 # We need a time or we think the database is not initialized 92 cu.execute('INSERT INTO lastupdate (highlander, timestamp) VALUES (1, CURRENT_TIMESTAMP);') 93 94 # Close, or not necessarily executed? 95 cx.close() 96 97 ############################# 98 # API methods below are used in querying/modifying the API for the 99 # record of the running state of loggers. 100 ############################ 101 def _get_connection(self): 102 """ Return SQLite connection or get one """ 103 104 def dict_factory(cursor, row): 105 """ Factory method for sqlite row as dictionary """ 106 d = {} 107 for idx, col in enumerate(cursor.description): 108 d[col[0]] = row[idx] 109 return d 110 111 # Return cached connection if it exists 112 if self.cx is not None: 113 return self.cx 114 115 # Otherwise establish a connection to database 116 if not self._database_exists(): 117 if self.no_create_database: 118 raise sqlite3.OperationalError(f'No database "{self.database_path}" found, ' 119 'and flag "no_create_database" is set.') 120 # Otherwise, create the missing database 121 self._create_database() 122 123 # Open the database for use 124 try: 125 cx = sqlite3.connect(self.database_path, 126 check_same_thread=False, 127 detect_types=sqlite3.PARSE_DECLTYPES) 128 except sqlite3.Error as err: 129 # Some other error 130 logging.error(f'SQLite database error: {err}') 131 raise err 132 133 # Database exists and is now open 134 cx.row_factory = dict_factory 135 cx.isolation_level = None 136 # cx.execute('PRAGMA ... etc...'); 137 # See if database is initialized 138 try: 139 cx.execute('SELECT timestamp from lastupdate') 140 except sqlite3.OperationalError: 141 # Database or table missing 142 logging.error('System error: SQLite database exists but is not initialized!') 143 raise sqlite3.OperationError 144 except sqlite3.Error as err: 145 # Some other error 146 logging.error(f'SQLite database error: {err}') 147 raise err 148 else: 149 self.cx = cx 150 return self.cx 151 152 ################################################################## 153 def _sql_query(self, query, *args): 154 """ Query the SQLite database. Do NOT use this method 155 for INSERT, UPDATE, or DELETE as it does not 156 commit nor update the timestamp """ 157 158 cx = self._get_connection() 159 try: 160 res = cx.execute(query, args) 161 rows = res.fetchall() 162 return rows 163 except sqlite3.OperationalError: 164 # No such table. We should be logging this. 165 logging.error('System error: SQLite database exists but has no tables?') 166 return None 167 except sqlite3.Error as err: 168 logging.error(f'SQLite database error: {err}') 169 raise err 170 171 ################################################################## 172 def _sql_cmd(self, query, *args): 173 """ Execute a SQL command that modifies the database """ 174 175 # NOTE(kped): Consider not using the cached connection 176 # in this function to help avoid possible threading 177 # concurrency issues. 178 cx = self._get_connection() 179 try: 180 res = cx.execute(query, args) 181 rows = res.fetchall() 182 # Update the database timestamp 183 Q = 'INSERT OR REPLACE INTO lastupdate VALUES (1, ?)' 184 now = datetime.utcnow() 185 cx.execute(Q, (now,)) 186 cx.commit() 187 # Note: If using WAL, checkpoint 188 return rows 189 except sqlite3.OperationalError: 190 # No such table 191 logging.error(f'No such SQLite table for query: {query}') 192 raise sqlite3.OperationalError 193 except sqlite3.Error as err: 194 logging.error(f'SQLite database error: {err}') 195 raise err 196 197 ################################################################## 198 def _save_config(self): 199 """Save our config object to the database""" 200 201 Q = 'INSERT OR REPLACE INTO cruise \ 202 (highlander, config, compressed) \ 203 VALUES (1, ?, ?)' 204 205 try: 206 ydump = yaml.dump(self.config, sort_keys=False) 207 logging.debug(f'YAML dump: "{ydump}"') 208 conf = bytes(ydump, 'utf-8') 209 logging.debug(f'YAML conf: "{conf}"') 210 if DATABASE_COMPRESS: 211 conf = gzip.compress(conf) 212 self._sql_cmd(Q, conf, DATABASE_COMPRESS) 213 cx = self._get_connection() 214 # VACUUM takes a millisecond or so, but keeps the database 215 # size small, which speeds us back up. 216 cx.execute('VACUUM',) 217 except Exception as err: 218 logging.warn(f'Failed to save SQLite database: {err}') 219 raise err 220 else: 221 # logging.info('Database save successful') 222 pass 223 224 ################################################################## 225 def _get_database_timestamp(self): 226 """Get the timestamp from the sqlite database""" 227 228 Q = 'SELECT timestamp from lastupdate' 229 cx = self._get_connection() 230 try: 231 res = cx.execute(Q) 232 row = res.fetchone() 233 if not row: # if no timestamp row, return 'time zero' 234 return EPOCH_TIME_ZERO 235 236 timestamp = row['timestamp'] 237 if isinstance(timestamp, str): 238 timestamp = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S.%f") 239 240 # Sanity check that we get what we expect 241 elif not isinstance(timestamp, datetime): 242 logging.warning('Got non-datetime timestamp from database!') 243 logging.warning(f'Type: "{type(timestamp)}", value: "{timestamp}"') 244 return timestamp 245 246 except sqlite3.OperationalError as err: 247 # No such table 248 logging.error('System error: SQLite database exists but is not initialized!') 249 raise err 250 except sqlite3.Error as err: 251 logging.error(f'Unhandled SQLite database error: {err}') 252 raise err 253 254 ################################################################## 255 def _do_we_need_to_reload(self): 256 """ Check database timestamp and reload config if needed """ 257 258 db_timestamp = self._get_database_timestamp() 259 if db_timestamp > self.timestamp or self.config == {}: 260 Q = """SELECT 261 config, compressed 262 FROM 263 cruise 264 WHERE 265 highlander=1""" 266 rows = self._sql_query(Q) 267 row0 = None 268 try: 269 row0 = rows[0] 270 except IndexError: 271 return None 272 273 if 'config' not in row0: 274 return None 275 conf = row0['config'] 276 277 # Wanted bzip, but build problems (probably install script) 278 if row0.get('compressed', 0): 279 conf = gzip.decompress(conf) 280 281 # Wanted JSON, but datetime objects aren't JSON 282 # serializable, so went with YAML. 283 conf = yaml.load(conf, Loader=yaml.FullLoader) 284 if 'loggers' not in conf: 285 return None 286 287 self.timestamp = db_timestamp 288 self.config = conf 289 290 # For each of the get_* function, check if the timestamp 291 # is newer than our current timestamp, pull config from 292 # the database. 293 ################################################################## 294 def get_configuration(self): 295 """" Return cruise config for specified cruise id. """ 296 297 self._do_we_need_to_reload() 298 return self.config or None 299 300 ############################ 301 def get_modes(self): 302 """ Return list of modes defined for given cruise. """ 303 304 config = self.get_configuration() 305 if not config: 306 return None 307 return list(config.get('modes', [])) 308 309 ############################ 310 def get_active_mode(self): 311 """ Return cruise config for specified cruise id.""" 312 313 config = self.get_configuration() 314 if not config: 315 return None 316 return config.get('active_mode') 317 318 ############################ 319 def get_default_mode(self): 320 """ Get the name of the default mode for the specified cruise 321 from the. data store. """ 322 323 config = self.get_configuration() 324 if not config: 325 return None 326 return config.get('default_mode') 327 328 ############################ 329 def get_logger(self, logger): 330 """Retrieve the logger spec for the specified logger id.""" 331 332 loggers = self.get_loggers() # which calls self._get_configuration 333 if logger not in loggers: 334 raise ValueError(f'No logger "{logger}" found') 335 return loggers.get(logger) 336 337 ############################ 338 def get_loggers(self): 339 """Get a dict of 340 {logger_id:{'configs':[<name_1>,<name_2>,...], 341 'active':<name>},...} 342 for all loggers. 343 """ 344 345 config = self.get_configuration() 346 if not config: 347 return {} 348 349 if 'loggers' not in config: 350 raise ValueError('No loggers found') 351 logger_configs = config.get('loggers') 352 if logger_configs is None: 353 raise ValueError('No logger configurations found') 354 355 # Fetch and insert the currently active config for each logger 356 # Note that this only changes our copy, not the config itself 357 for logger in logger_configs: 358 if 'active' not in logger_configs[logger]: 359 mode = self.get_logger_config_name(logger) 360 logger_configs[logger]['active'] = mode 361 return logger_configs 362 363 ############################ 364 def get_logger_config(self, config_name): 365 """Retrieve the config associated with the specified name.""" 366 367 config = self.get_configuration() 368 if config is None: 369 return {} 370 logger_configs = config.get('configs') 371 if logger_configs is None: 372 raise ValueError('No "configs" section found') 373 logger_config = logger_configs.get(config_name) 374 if logger_config is None: 375 raise ValueError(f'No logger config "{config_name}" in config') 376 return logger_config 377 378 ############################ 379 def get_logger_configs(self, mode=None): 380 """Retrieve the configs associated with a cruise id and mode from the 381 data store. If mode is omitted, retrieve configs associated with 382 the cruise's current logger configs.""" 383 384 loggers = self.get_loggers() 385 if not loggers: 386 return None 387 388 output = {} 389 for logger in loggers: 390 logger_config_name = self.get_logger_config_name(logger, mode) 391 output[logger] = self.get_logger_config(logger_config_name) 392 393 return output 394 395 ############################ 396 def get_logger_config_name(self, logger_id, mode=None): 397 """ Retrieve name of the config associated with the specified logger 398 in the specified mode. If mode is omitted, retrieve name of logger's 399 current config. """ 400 401 config = self.get_configuration() 402 if not config: 403 return {} 404 loggers = config.get('loggers') 405 if loggers is None: 406 raise ValueError('No loggers found in config') 407 408 # No mode, so we want the active mode 409 if mode is None: 410 logger = loggers.get(logger_id) 411 if logger is None: 412 raise ValueError(f'Logger id {logger_id} has no mode!') 413 conf_name = logger.get('active') 414 if conf_name is not None: 415 return conf_name 416 417 # Mode given or no active conf, so get the default for this mode 418 modes = config.get('modes') 419 mode_configs = modes.get(mode) 420 if mode_configs is None: 421 raise ValueError(f'Requested mode {mode} is not defined') 422 logger_config_name = mode_configs.get(logger_id) 423 if logger_config_name is None: 424 raise ValueError(f'Logger {logger_id} has no config defined in mode {mode}') 425 return logger_config_name 426 427 ############################# 428 def get_logger_config_names(self, logger_id): 429 """ Retrieve list of config names that are valid for the 430 specified logger . 431 > api.get_logger_config_names('NBP1406', 'knud') 432 ["off", "knud->net", "knud->net/file", "knud->net/file/db"] 433 """ 434 logger = self.get_logger(logger_id) 435 return logger.get('configs', []) 436 437 ############################ 438 # Methods for manipulating the desired state via API to indicate 439 # current mode and which loggers should be in which configs. 440 ############################ 441 def set_active_mode(self, mode): 442 """Set the current mode of the specified cruise in the data store.""" 443 444 config = self.get_configuration() 445 modes = config.get('modes') 446 if not modes: 447 raise ValueError('Config has no modes') 448 if mode not in modes: 449 raise ValueError(f'Config has no mode "{mode}"') 450 451 self.config['active_mode'] = mode 452 453 # Update the API's working config's loggers 454 # to match the new mode 455 for logger, conf in modes[mode].items(): 456 self.config['loggers'][logger]['active'] = conf 457 458 self._save_config() 459 logging.info('Signaling update') 460 self.signal_update() 461 462 ############################ 463 def set_active_logger_config(self, logger, config_name): 464 """Set specified logger to new config. NOTE: we have no way to check 465 whether logger is compatible with config, so we rely on whoever is 466 calling us to have made that determination.""" 467 468 # self.logger_config[logger] = config_name 469 # NOTE: We can check that config_name is in logger[configs] 470 self.config['loggers'][logger]['active'] = config_name 471 self._save_config() 472 logging.info('Signaling update') 473 self.signal_update() 474 475 ############################ 476 # Methods for feeding data from LoggerServer back into the API 477 ############################ 478 def update_status(self, status): 479 """Save/register the loggers' retrieved status report with the API.""" 480 self.status.append(((datetime.utcnow()), status)) 481 # NOTE(kped) Do we need to write this to the database? 482 # logger_manager never calls this.... 483 484 ############################ 485 # Methods for getting status data from API 486 ############################ 487 488 def get_status(self, since_timestamp=None): 489 """Retrieve a dict of the most-recent status report from each 490 logger. If since_timestamp is specified, retrieve all status reports 491 since that time.""" 492 493 # Start by getting set of loggers for cruise. Store as 494 # cruise_id:logger for ease of lookup. 495 try: 496 logger_set = set([logger 497 for logger in self.get_loggers()]) 498 except ValueError: 499 logger_set = set() 500 501 logging.debug(f'logger_set: {logger_set}') 502 503 # Step backwards through status messages until we run out of 504 # status messages or reach termination condition. If 505 # since_timestamp==None, our termination is when we have a status 506 # for each of our loggers. If since_timestamp is a number, our 507 # termination is when we've grabbed all the statuses with a 508 # timestamp greater than the specified number. 509 status = {} 510 511 status_index = len(self.status) - 1 512 logging.debug(f'starting at status index {status_index}') 513 while logger_set and status_index >= 0: 514 # record is a dict of 'cruise_id:logger' : {fields} 515 (timestamp, record) = self.status[status_index] 516 logging.debug('%d: %f: %s', 517 status_index, timestamp, pprint.pformat(record)) 518 519 # If we've been given a numeric timestamp and we've stepped back 520 # in time to or before that timestamp, we're done - break out. 521 if since_timestamp is not None and timestamp <= since_timestamp: 522 break 523 524 # Otherwise, examine ids in this record to see if they're for 525 # the cruise in question. 526 for id, fields in record.items(): 527 # If id is cruise_id:logger that we're interested in, grab it. 528 logging.debug(f'Is {id} in {logger_set}?') 529 if id in logger_set: 530 if timestamp not in status: 531 status[timestamp] = {} 532 status[timestamp][id] = fields 533 534 # If since_timestamp==None, we only want the latest status 535 # for each logger. So once we've found it, remove the id 536 # from the logger_set we're lookings. We'll drop out of the 537 # loop when the set is empty. 538 if since_timestamp is None: 539 logger_set.discard(id) 540 status_index -= 1 541 542 return status 543 544 ############################ 545 # Methods for storing/retrieving messages from servers/loggers/etc. 546 ############################ 547 def message_log(self, source, user, log_level, message): 548 """ Timestamp and store the passed message. """ 549 550 now = datetime.utcnow() 551 self.server_messages.append((now, source, user, 552 log_level, message)) 553 554 # Keep server_messages from over-eating memory 555 while len(self.server_messages) > 1000: 556 self.server_messages.pop(0) 557 558 Q = 'INSERT INTO logmessages \ 559 (timestamp, loglevel, cruise, source, user, message) \ 560 VALUES(?, ?, ?, ?, ?, ?)' 561 562 cruise = self.config.get('cruise', {}) 563 cruise_id = cruise.get('id', 'none') 564 565 self._sql_cmd(Q, now, log_level, cruise_id, source, user, message) 566 567 ############################ 568 def get_message_log(self, source=None, user=None, log_level=sys.maxsize, 569 since_timestamp=None): 570 """Retrieve log messages from source at or above log_level since 571 timestamp. If source is omitted, retrieve from all sources. If 572 log_level is omitted, retrieve at all levels. If since_timestamp is 573 omitted, only retrieve most recent message. 574 """ 575 576 # NOTE: Should we pull this from the database? 577 # No... if they want more history, look directly. 578 index = len(self.server_messages) - 1 579 messages = [] 580 while index >= 0: 581 message = self.server_messages[index] 582 (timestamp, mesg_source, mesg_user, 583 mesg_log_level, mesg_message) = message 584 # Have we gone back too far? If so, we're done. 585 if since_timestamp is not None and timestamp <= since_timestamp: 586 break 587 588 if mesg_log_level < log_level: 589 continue 590 if user and not mesg_user == user: 591 continue 592 if source and not mesg_source == source: 593 continue 594 595 messages.insert(0, message) 596 597 # Are we only looking for last message, and do we have a message? 598 if since_timestamp is None and messages: 599 break 600 index -= 1 601 602 return messages 603 604 ############################# 605 # Save a copy before empyting out the database 606 ############################## 607 def _backup_database(self): 608 """ Backup the database """ 609 610 config = self.get_configuration() 611 if not config: 612 logging.debug('No configuration to back up') 613 return 614 cruise = config.get('cruise', {}) 615 cruise_id = cruise.get('id', 'none') 616 617 dt = datetime.utcnow().strftime('%Y%m%d%H%M%S') 618 ourpath = os.path.dirname(__file__) 619 filename = f'openrvdas-{cruise_id}-{dt}.sql' 620 dbfile = os.path.join(ourpath, filename) 621 try: 622 cx = self._get_connection() 623 cx.execute('VACUUM INTO ?', (dbfile,)) 624 except Exception as err: 625 logging.warn(f'Failed to backup SQLite database: {err}') 626 pass 627 628 ##################################### 629 def load_configuration(self, config): 630 """Add a complete cruise configuration (id, modes, configs, 631 default) to the data store.""" 632 633 # Loaded new config, (optionally) backup old one 634 if DATABASE_BACKUPS: 635 self._backup_database() 636 637 self.config = config 638 # self.config['loaded_time'] = datetime.utcnow().isoformat() 639 self.config['loaded_time'] = datetime.utcnow() 640 641 # Some syntactic sugar to simplify config definitions 642 configs = self.config.get('configs') 643 for config_name, config in configs.items(): 644 if config is None: 645 raise ValueError(f'No logger for "{config_name}" in cruise definition') 646 if 'name' not in config: 647 self.config['configs'][config_name]['name'] = config_name 648 649 # Set cruise into default mode, if one is defined 650 if 'default_mode' in self.config: 651 active_mode = self.config['default_mode'] 652 self.set_active_mode(active_mode) 653 else: 654 logging.warn('Cruise has no default mode') 655 # Why not send the entire config to the CDS? Why 656 # just *almost* all of it? JSON issue? 657 cruise = self.config.get('cruise') 658 if cruise: 659 for key in ['id', 'start', 'end']: 660 if key not in self.config: 661 self.config[key] = cruise.get(key) 662 self._save_config() 663 self.signal_load() 664 665 ############################### 666 def delete_configuration(self): 667 """Remove the specified cruise from the data store.""" 668 self.config = {} 669 # self.logger_config = {} 670 self.callbacks = [] 671 self.status = [] 672 self._save_config() 673 674 ############################ 675 # Methods for manually constructing/modifying a cruise spec via API 676 def add_mode(self, cruise_id, mode): 677 logging.warn('Method "add_mode" not implemented') 678 679 def delete_mode(self, cruise_id, mode): 680 logging.warn('Method "delete_mode" not implemented') 681 682 def add_logger(self, cruise_id, logger_id, logger_spec): 683 logging.warn('Method "add_logger" not implemented') 684 685 def delete_logger(self, cruise_id, logger_id): 686 logging.warn('Method "delete_logger" not implemented') 687 688 def add_config(self, cruise_id, config, config_spec): 689 logging.warn('Method "add_config" not implemented') 690 691 def add_config_to_logger(self, cruise_id, config, logger_id): 692 logging.warn('Method "add_config_to_logger" not implemented') 693 694 def add_config_to_mode(self, cruise_id, config, logger_id, mode): 695 logging.warn('Method "add_config_to_mode" not implemented') 696 697 def delete_config(self, cruise_id, config_id): 698 logging.warn('Method "delete_config" not implemented')
46class SQLiteServerAPI(ServerAPI): 47 ############################ 48 def __init__(self, database_path=DEFAULT_DATABASE_PATH, 49 no_create_database=False): 50 """ 51 database_path - If specified, the path to the SQLite database to use 52 53 no_create_database - If True, and database does not exist, throw an error 54 rather than creating a new database. 55 """ 56 super().__init__() 57 58 # Where do we l 59 self.database_path = database_path 60 self.no_create_database = no_create_database 61 62 self.config = {} 63 self.callbacks = [] 64 self.status = [] 65 self.server_messages = [] 66 self.cx = None 67 self.timestamp = self._get_database_timestamp() 68 69 def _database_exists(self): 70 """Return True if SQLite database at self.database_path exists, 71 and can be read without errors.""" 72 read_only_database_path = ''.join(['file:', self.database_path, '?mode=ro']) 73 try: 74 cx = sqlite3.connect(read_only_database_path, uri=True) # noqa: F841 75 except sqlite3.OperationalError: 76 return False 77 except sqlite3.Error as err: 78 # Some other error 79 logging.error(f'Unknown SQLite database error on read: {err}') 80 return False 81 return True 82 83 def _create_database(self): 84 """Try to create the database.""" 85 logging.debug(f'Creating SQLite database "{self.database_path}"') 86 cx = sqlite3.connect(self.database_path) 87 cu = cx.cursor() 88 cu.execute('CREATE TABLE Cruise (highlander integer primary key not null, config blob, [loaded_time] datetime, compressed integer)') # noqa E501 89 cu.execute('CREATE TABLE lastupdate (highlander integer primary key not null, [timestamp] datetime)') # noqa E501 90 cu.execute('CREATE TABLE logmessages (timestamp datetime primary key not null, loglevel integer, cruise text, source text, user text, message text)') # noqa E501 91 92 # We need a time or we think the database is not initialized 93 cu.execute('INSERT INTO lastupdate (highlander, timestamp) VALUES (1, CURRENT_TIMESTAMP);') 94 95 # Close, or not necessarily executed? 96 cx.close() 97 98 ############################# 99 # API methods below are used in querying/modifying the API for the 100 # record of the running state of loggers. 101 ############################ 102 def _get_connection(self): 103 """ Return SQLite connection or get one """ 104 105 def dict_factory(cursor, row): 106 """ Factory method for sqlite row as dictionary """ 107 d = {} 108 for idx, col in enumerate(cursor.description): 109 d[col[0]] = row[idx] 110 return d 111 112 # Return cached connection if it exists 113 if self.cx is not None: 114 return self.cx 115 116 # Otherwise establish a connection to database 117 if not self._database_exists(): 118 if self.no_create_database: 119 raise sqlite3.OperationalError(f'No database "{self.database_path}" found, ' 120 'and flag "no_create_database" is set.') 121 # Otherwise, create the missing database 122 self._create_database() 123 124 # Open the database for use 125 try: 126 cx = sqlite3.connect(self.database_path, 127 check_same_thread=False, 128 detect_types=sqlite3.PARSE_DECLTYPES) 129 except sqlite3.Error as err: 130 # Some other error 131 logging.error(f'SQLite database error: {err}') 132 raise err 133 134 # Database exists and is now open 135 cx.row_factory = dict_factory 136 cx.isolation_level = None 137 # cx.execute('PRAGMA ... etc...'); 138 # See if database is initialized 139 try: 140 cx.execute('SELECT timestamp from lastupdate') 141 except sqlite3.OperationalError: 142 # Database or table missing 143 logging.error('System error: SQLite database exists but is not initialized!') 144 raise sqlite3.OperationError 145 except sqlite3.Error as err: 146 # Some other error 147 logging.error(f'SQLite database error: {err}') 148 raise err 149 else: 150 self.cx = cx 151 return self.cx 152 153 ################################################################## 154 def _sql_query(self, query, *args): 155 """ Query the SQLite database. Do NOT use this method 156 for INSERT, UPDATE, or DELETE as it does not 157 commit nor update the timestamp """ 158 159 cx = self._get_connection() 160 try: 161 res = cx.execute(query, args) 162 rows = res.fetchall() 163 return rows 164 except sqlite3.OperationalError: 165 # No such table. We should be logging this. 166 logging.error('System error: SQLite database exists but has no tables?') 167 return None 168 except sqlite3.Error as err: 169 logging.error(f'SQLite database error: {err}') 170 raise err 171 172 ################################################################## 173 def _sql_cmd(self, query, *args): 174 """ Execute a SQL command that modifies the database """ 175 176 # NOTE(kped): Consider not using the cached connection 177 # in this function to help avoid possible threading 178 # concurrency issues. 179 cx = self._get_connection() 180 try: 181 res = cx.execute(query, args) 182 rows = res.fetchall() 183 # Update the database timestamp 184 Q = 'INSERT OR REPLACE INTO lastupdate VALUES (1, ?)' 185 now = datetime.utcnow() 186 cx.execute(Q, (now,)) 187 cx.commit() 188 # Note: If using WAL, checkpoint 189 return rows 190 except sqlite3.OperationalError: 191 # No such table 192 logging.error(f'No such SQLite table for query: {query}') 193 raise sqlite3.OperationalError 194 except sqlite3.Error as err: 195 logging.error(f'SQLite database error: {err}') 196 raise err 197 198 ################################################################## 199 def _save_config(self): 200 """Save our config object to the database""" 201 202 Q = 'INSERT OR REPLACE INTO cruise \ 203 (highlander, config, compressed) \ 204 VALUES (1, ?, ?)' 205 206 try: 207 ydump = yaml.dump(self.config, sort_keys=False) 208 logging.debug(f'YAML dump: "{ydump}"') 209 conf = bytes(ydump, 'utf-8') 210 logging.debug(f'YAML conf: "{conf}"') 211 if DATABASE_COMPRESS: 212 conf = gzip.compress(conf) 213 self._sql_cmd(Q, conf, DATABASE_COMPRESS) 214 cx = self._get_connection() 215 # VACUUM takes a millisecond or so, but keeps the database 216 # size small, which speeds us back up. 217 cx.execute('VACUUM',) 218 except Exception as err: 219 logging.warn(f'Failed to save SQLite database: {err}') 220 raise err 221 else: 222 # logging.info('Database save successful') 223 pass 224 225 ################################################################## 226 def _get_database_timestamp(self): 227 """Get the timestamp from the sqlite database""" 228 229 Q = 'SELECT timestamp from lastupdate' 230 cx = self._get_connection() 231 try: 232 res = cx.execute(Q) 233 row = res.fetchone() 234 if not row: # if no timestamp row, return 'time zero' 235 return EPOCH_TIME_ZERO 236 237 timestamp = row['timestamp'] 238 if isinstance(timestamp, str): 239 timestamp = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S.%f") 240 241 # Sanity check that we get what we expect 242 elif not isinstance(timestamp, datetime): 243 logging.warning('Got non-datetime timestamp from database!') 244 logging.warning(f'Type: "{type(timestamp)}", value: "{timestamp}"') 245 return timestamp 246 247 except sqlite3.OperationalError as err: 248 # No such table 249 logging.error('System error: SQLite database exists but is not initialized!') 250 raise err 251 except sqlite3.Error as err: 252 logging.error(f'Unhandled SQLite database error: {err}') 253 raise err 254 255 ################################################################## 256 def _do_we_need_to_reload(self): 257 """ Check database timestamp and reload config if needed """ 258 259 db_timestamp = self._get_database_timestamp() 260 if db_timestamp > self.timestamp or self.config == {}: 261 Q = """SELECT 262 config, compressed 263 FROM 264 cruise 265 WHERE 266 highlander=1""" 267 rows = self._sql_query(Q) 268 row0 = None 269 try: 270 row0 = rows[0] 271 except IndexError: 272 return None 273 274 if 'config' not in row0: 275 return None 276 conf = row0['config'] 277 278 # Wanted bzip, but build problems (probably install script) 279 if row0.get('compressed', 0): 280 conf = gzip.decompress(conf) 281 282 # Wanted JSON, but datetime objects aren't JSON 283 # serializable, so went with YAML. 284 conf = yaml.load(conf, Loader=yaml.FullLoader) 285 if 'loggers' not in conf: 286 return None 287 288 self.timestamp = db_timestamp 289 self.config = conf 290 291 # For each of the get_* function, check if the timestamp 292 # is newer than our current timestamp, pull config from 293 # the database. 294 ################################################################## 295 def get_configuration(self): 296 """" Return cruise config for specified cruise id. """ 297 298 self._do_we_need_to_reload() 299 return self.config or None 300 301 ############################ 302 def get_modes(self): 303 """ Return list of modes defined for given cruise. """ 304 305 config = self.get_configuration() 306 if not config: 307 return None 308 return list(config.get('modes', [])) 309 310 ############################ 311 def get_active_mode(self): 312 """ Return cruise config for specified cruise id.""" 313 314 config = self.get_configuration() 315 if not config: 316 return None 317 return config.get('active_mode') 318 319 ############################ 320 def get_default_mode(self): 321 """ Get the name of the default mode for the specified cruise 322 from the. data store. """ 323 324 config = self.get_configuration() 325 if not config: 326 return None 327 return config.get('default_mode') 328 329 ############################ 330 def get_logger(self, logger): 331 """Retrieve the logger spec for the specified logger id.""" 332 333 loggers = self.get_loggers() # which calls self._get_configuration 334 if logger not in loggers: 335 raise ValueError(f'No logger "{logger}" found') 336 return loggers.get(logger) 337 338 ############################ 339 def get_loggers(self): 340 """Get a dict of 341 {logger_id:{'configs':[<name_1>,<name_2>,...], 342 'active':<name>},...} 343 for all loggers. 344 """ 345 346 config = self.get_configuration() 347 if not config: 348 return {} 349 350 if 'loggers' not in config: 351 raise ValueError('No loggers found') 352 logger_configs = config.get('loggers') 353 if logger_configs is None: 354 raise ValueError('No logger configurations found') 355 356 # Fetch and insert the currently active config for each logger 357 # Note that this only changes our copy, not the config itself 358 for logger in logger_configs: 359 if 'active' not in logger_configs[logger]: 360 mode = self.get_logger_config_name(logger) 361 logger_configs[logger]['active'] = mode 362 return logger_configs 363 364 ############################ 365 def get_logger_config(self, config_name): 366 """Retrieve the config associated with the specified name.""" 367 368 config = self.get_configuration() 369 if config is None: 370 return {} 371 logger_configs = config.get('configs') 372 if logger_configs is None: 373 raise ValueError('No "configs" section found') 374 logger_config = logger_configs.get(config_name) 375 if logger_config is None: 376 raise ValueError(f'No logger config "{config_name}" in config') 377 return logger_config 378 379 ############################ 380 def get_logger_configs(self, mode=None): 381 """Retrieve the configs associated with a cruise id and mode from the 382 data store. If mode is omitted, retrieve configs associated with 383 the cruise's current logger configs.""" 384 385 loggers = self.get_loggers() 386 if not loggers: 387 return None 388 389 output = {} 390 for logger in loggers: 391 logger_config_name = self.get_logger_config_name(logger, mode) 392 output[logger] = self.get_logger_config(logger_config_name) 393 394 return output 395 396 ############################ 397 def get_logger_config_name(self, logger_id, mode=None): 398 """ Retrieve name of the config associated with the specified logger 399 in the specified mode. If mode is omitted, retrieve name of logger's 400 current config. """ 401 402 config = self.get_configuration() 403 if not config: 404 return {} 405 loggers = config.get('loggers') 406 if loggers is None: 407 raise ValueError('No loggers found in config') 408 409 # No mode, so we want the active mode 410 if mode is None: 411 logger = loggers.get(logger_id) 412 if logger is None: 413 raise ValueError(f'Logger id {logger_id} has no mode!') 414 conf_name = logger.get('active') 415 if conf_name is not None: 416 return conf_name 417 418 # Mode given or no active conf, so get the default for this mode 419 modes = config.get('modes') 420 mode_configs = modes.get(mode) 421 if mode_configs is None: 422 raise ValueError(f'Requested mode {mode} is not defined') 423 logger_config_name = mode_configs.get(logger_id) 424 if logger_config_name is None: 425 raise ValueError(f'Logger {logger_id} has no config defined in mode {mode}') 426 return logger_config_name 427 428 ############################# 429 def get_logger_config_names(self, logger_id): 430 """ Retrieve list of config names that are valid for the 431 specified logger . 432 > api.get_logger_config_names('NBP1406', 'knud') 433 ["off", "knud->net", "knud->net/file", "knud->net/file/db"] 434 """ 435 logger = self.get_logger(logger_id) 436 return logger.get('configs', []) 437 438 ############################ 439 # Methods for manipulating the desired state via API to indicate 440 # current mode and which loggers should be in which configs. 441 ############################ 442 def set_active_mode(self, mode): 443 """Set the current mode of the specified cruise in the data store.""" 444 445 config = self.get_configuration() 446 modes = config.get('modes') 447 if not modes: 448 raise ValueError('Config has no modes') 449 if mode not in modes: 450 raise ValueError(f'Config has no mode "{mode}"') 451 452 self.config['active_mode'] = mode 453 454 # Update the API's working config's loggers 455 # to match the new mode 456 for logger, conf in modes[mode].items(): 457 self.config['loggers'][logger]['active'] = conf 458 459 self._save_config() 460 logging.info('Signaling update') 461 self.signal_update() 462 463 ############################ 464 def set_active_logger_config(self, logger, config_name): 465 """Set specified logger to new config. NOTE: we have no way to check 466 whether logger is compatible with config, so we rely on whoever is 467 calling us to have made that determination.""" 468 469 # self.logger_config[logger] = config_name 470 # NOTE: We can check that config_name is in logger[configs] 471 self.config['loggers'][logger]['active'] = config_name 472 self._save_config() 473 logging.info('Signaling update') 474 self.signal_update() 475 476 ############################ 477 # Methods for feeding data from LoggerServer back into the API 478 ############################ 479 def update_status(self, status): 480 """Save/register the loggers' retrieved status report with the API.""" 481 self.status.append(((datetime.utcnow()), status)) 482 # NOTE(kped) Do we need to write this to the database? 483 # logger_manager never calls this.... 484 485 ############################ 486 # Methods for getting status data from API 487 ############################ 488 489 def get_status(self, since_timestamp=None): 490 """Retrieve a dict of the most-recent status report from each 491 logger. If since_timestamp is specified, retrieve all status reports 492 since that time.""" 493 494 # Start by getting set of loggers for cruise. Store as 495 # cruise_id:logger for ease of lookup. 496 try: 497 logger_set = set([logger 498 for logger in self.get_loggers()]) 499 except ValueError: 500 logger_set = set() 501 502 logging.debug(f'logger_set: {logger_set}') 503 504 # Step backwards through status messages until we run out of 505 # status messages or reach termination condition. If 506 # since_timestamp==None, our termination is when we have a status 507 # for each of our loggers. If since_timestamp is a number, our 508 # termination is when we've grabbed all the statuses with a 509 # timestamp greater than the specified number. 510 status = {} 511 512 status_index = len(self.status) - 1 513 logging.debug(f'starting at status index {status_index}') 514 while logger_set and status_index >= 0: 515 # record is a dict of 'cruise_id:logger' : {fields} 516 (timestamp, record) = self.status[status_index] 517 logging.debug('%d: %f: %s', 518 status_index, timestamp, pprint.pformat(record)) 519 520 # If we've been given a numeric timestamp and we've stepped back 521 # in time to or before that timestamp, we're done - break out. 522 if since_timestamp is not None and timestamp <= since_timestamp: 523 break 524 525 # Otherwise, examine ids in this record to see if they're for 526 # the cruise in question. 527 for id, fields in record.items(): 528 # If id is cruise_id:logger that we're interested in, grab it. 529 logging.debug(f'Is {id} in {logger_set}?') 530 if id in logger_set: 531 if timestamp not in status: 532 status[timestamp] = {} 533 status[timestamp][id] = fields 534 535 # If since_timestamp==None, we only want the latest status 536 # for each logger. So once we've found it, remove the id 537 # from the logger_set we're lookings. We'll drop out of the 538 # loop when the set is empty. 539 if since_timestamp is None: 540 logger_set.discard(id) 541 status_index -= 1 542 543 return status 544 545 ############################ 546 # Methods for storing/retrieving messages from servers/loggers/etc. 547 ############################ 548 def message_log(self, source, user, log_level, message): 549 """ Timestamp and store the passed message. """ 550 551 now = datetime.utcnow() 552 self.server_messages.append((now, source, user, 553 log_level, message)) 554 555 # Keep server_messages from over-eating memory 556 while len(self.server_messages) > 1000: 557 self.server_messages.pop(0) 558 559 Q = 'INSERT INTO logmessages \ 560 (timestamp, loglevel, cruise, source, user, message) \ 561 VALUES(?, ?, ?, ?, ?, ?)' 562 563 cruise = self.config.get('cruise', {}) 564 cruise_id = cruise.get('id', 'none') 565 566 self._sql_cmd(Q, now, log_level, cruise_id, source, user, message) 567 568 ############################ 569 def get_message_log(self, source=None, user=None, log_level=sys.maxsize, 570 since_timestamp=None): 571 """Retrieve log messages from source at or above log_level since 572 timestamp. If source is omitted, retrieve from all sources. If 573 log_level is omitted, retrieve at all levels. If since_timestamp is 574 omitted, only retrieve most recent message. 575 """ 576 577 # NOTE: Should we pull this from the database? 578 # No... if they want more history, look directly. 579 index = len(self.server_messages) - 1 580 messages = [] 581 while index >= 0: 582 message = self.server_messages[index] 583 (timestamp, mesg_source, mesg_user, 584 mesg_log_level, mesg_message) = message 585 # Have we gone back too far? If so, we're done. 586 if since_timestamp is not None and timestamp <= since_timestamp: 587 break 588 589 if mesg_log_level < log_level: 590 continue 591 if user and not mesg_user == user: 592 continue 593 if source and not mesg_source == source: 594 continue 595 596 messages.insert(0, message) 597 598 # Are we only looking for last message, and do we have a message? 599 if since_timestamp is None and messages: 600 break 601 index -= 1 602 603 return messages 604 605 ############################# 606 # Save a copy before empyting out the database 607 ############################## 608 def _backup_database(self): 609 """ Backup the database """ 610 611 config = self.get_configuration() 612 if not config: 613 logging.debug('No configuration to back up') 614 return 615 cruise = config.get('cruise', {}) 616 cruise_id = cruise.get('id', 'none') 617 618 dt = datetime.utcnow().strftime('%Y%m%d%H%M%S') 619 ourpath = os.path.dirname(__file__) 620 filename = f'openrvdas-{cruise_id}-{dt}.sql' 621 dbfile = os.path.join(ourpath, filename) 622 try: 623 cx = self._get_connection() 624 cx.execute('VACUUM INTO ?', (dbfile,)) 625 except Exception as err: 626 logging.warn(f'Failed to backup SQLite database: {err}') 627 pass 628 629 ##################################### 630 def load_configuration(self, config): 631 """Add a complete cruise configuration (id, modes, configs, 632 default) to the data store.""" 633 634 # Loaded new config, (optionally) backup old one 635 if DATABASE_BACKUPS: 636 self._backup_database() 637 638 self.config = config 639 # self.config['loaded_time'] = datetime.utcnow().isoformat() 640 self.config['loaded_time'] = datetime.utcnow() 641 642 # Some syntactic sugar to simplify config definitions 643 configs = self.config.get('configs') 644 for config_name, config in configs.items(): 645 if config is None: 646 raise ValueError(f'No logger for "{config_name}" in cruise definition') 647 if 'name' not in config: 648 self.config['configs'][config_name]['name'] = config_name 649 650 # Set cruise into default mode, if one is defined 651 if 'default_mode' in self.config: 652 active_mode = self.config['default_mode'] 653 self.set_active_mode(active_mode) 654 else: 655 logging.warn('Cruise has no default mode') 656 # Why not send the entire config to the CDS? Why 657 # just *almost* all of it? JSON issue? 658 cruise = self.config.get('cruise') 659 if cruise: 660 for key in ['id', 'start', 'end']: 661 if key not in self.config: 662 self.config[key] = cruise.get(key) 663 self._save_config() 664 self.signal_load() 665 666 ############################### 667 def delete_configuration(self): 668 """Remove the specified cruise from the data store.""" 669 self.config = {} 670 # self.logger_config = {} 671 self.callbacks = [] 672 self.status = [] 673 self._save_config() 674 675 ############################ 676 # Methods for manually constructing/modifying a cruise spec via API 677 def add_mode(self, cruise_id, mode): 678 logging.warn('Method "add_mode" not implemented') 679 680 def delete_mode(self, cruise_id, mode): 681 logging.warn('Method "delete_mode" not implemented') 682 683 def add_logger(self, cruise_id, logger_id, logger_spec): 684 logging.warn('Method "add_logger" not implemented') 685 686 def delete_logger(self, cruise_id, logger_id): 687 logging.warn('Method "delete_logger" not implemented') 688 689 def add_config(self, cruise_id, config, config_spec): 690 logging.warn('Method "add_config" not implemented') 691 692 def add_config_to_logger(self, cruise_id, config, logger_id): 693 logging.warn('Method "add_config_to_logger" not implemented') 694 695 def add_config_to_mode(self, cruise_id, config, logger_id, mode): 696 logging.warn('Method "add_config_to_mode" not implemented') 697 698 def delete_config(self, cruise_id, config_id): 699 logging.warn('Method "delete_config" not implemented')
Abstract base class defining an API through which a LoggerServer can interact with a data store.
Parameters below have the following semantics:
configuration - dict definition of a OpenRVDAS configuration
mode - dict name of mode, and logger_config_names associated with that mode
default_mode - name of mode to use at startup or when returning to a default state
logger_config_name - string name of a logger configuration, unique within configration
logger_config - dict definition of a logger configuration
logger_id - string name of logger, unique within cruise
logger - dict definition of logger, including list of names of
valid configs and optional host restriction
logger_configs - dict of {logger_config_name:logger_config,...}
For the purposes of documentation below, assume a sample cruise_config as follows:
{
"loggers": {
"knud": {
"host": "knud.pi",
"configs": ["off", "knud->net", "knud->file/net/db"]
},
"gyr1": {
"configs": ["off", "gyr1->net", "gyr1->file/net/db"]
},
"modes": {
"off": {"knud": "off", "gyr1": "off"},
"port": {"knud": "off", "gyr1": "gyr1->net"},
"underway": { "knud": "knud->file/net/db",
"gyr1": "gyr1->file/net/db"
}
},
"default_mode": "off",
"configs": {
"off": {},
"knud->net": { config_spec },
"knud->file/net/db": { config_spec },
"gyr1->net": { config_spec },
"gyr1->file/net/dbnet": { config_spec }
}
}
48 def __init__(self, database_path=DEFAULT_DATABASE_PATH, 49 no_create_database=False): 50 """ 51 database_path - If specified, the path to the SQLite database to use 52 53 no_create_database - If True, and database does not exist, throw an error 54 rather than creating a new database. 55 """ 56 super().__init__() 57 58 # Where do we l 59 self.database_path = database_path 60 self.no_create_database = no_create_database 61 62 self.config = {} 63 self.callbacks = [] 64 self.status = [] 65 self.server_messages = [] 66 self.cx = None 67 self.timestamp = self._get_database_timestamp()
database_path - If specified, the path to the SQLite database to use
no_create_database - If True, and database does not exist, throw an error rather than creating a new database.
295 def get_configuration(self): 296 """" Return cruise config for specified cruise id. """ 297 298 self._do_we_need_to_reload() 299 return self.config or None
" Return cruise config for specified cruise id.
302 def get_modes(self): 303 """ Return list of modes defined for given cruise. """ 304 305 config = self.get_configuration() 306 if not config: 307 return None 308 return list(config.get('modes', []))
Return list of modes defined for given cruise.
311 def get_active_mode(self): 312 """ Return cruise config for specified cruise id.""" 313 314 config = self.get_configuration() 315 if not config: 316 return None 317 return config.get('active_mode')
Return cruise config for specified cruise id.
320 def get_default_mode(self): 321 """ Get the name of the default mode for the specified cruise 322 from the. data store. """ 323 324 config = self.get_configuration() 325 if not config: 326 return None 327 return config.get('default_mode')
Get the name of the default mode for the specified cruise from the. data store.
330 def get_logger(self, logger): 331 """Retrieve the logger spec for the specified logger id.""" 332 333 loggers = self.get_loggers() # which calls self._get_configuration 334 if logger not in loggers: 335 raise ValueError(f'No logger "{logger}" found') 336 return loggers.get(logger)
Retrieve the logger spec for the specified logger id.
339 def get_loggers(self): 340 """Get a dict of 341 {logger_id:{'configs':[<name_1>,<name_2>,...], 342 'active':<name>},...} 343 for all loggers. 344 """ 345 346 config = self.get_configuration() 347 if not config: 348 return {} 349 350 if 'loggers' not in config: 351 raise ValueError('No loggers found') 352 logger_configs = config.get('loggers') 353 if logger_configs is None: 354 raise ValueError('No logger configurations found') 355 356 # Fetch and insert the currently active config for each logger 357 # Note that this only changes our copy, not the config itself 358 for logger in logger_configs: 359 if 'active' not in logger_configs[logger]: 360 mode = self.get_logger_config_name(logger) 361 logger_configs[logger]['active'] = mode 362 return logger_configs
Get a dict of
{logger_id:{'configs':[
365 def get_logger_config(self, config_name): 366 """Retrieve the config associated with the specified name.""" 367 368 config = self.get_configuration() 369 if config is None: 370 return {} 371 logger_configs = config.get('configs') 372 if logger_configs is None: 373 raise ValueError('No "configs" section found') 374 logger_config = logger_configs.get(config_name) 375 if logger_config is None: 376 raise ValueError(f'No logger config "{config_name}" in config') 377 return logger_config
Retrieve the config associated with the specified name.
380 def get_logger_configs(self, mode=None): 381 """Retrieve the configs associated with a cruise id and mode from the 382 data store. If mode is omitted, retrieve configs associated with 383 the cruise's current logger configs.""" 384 385 loggers = self.get_loggers() 386 if not loggers: 387 return None 388 389 output = {} 390 for logger in loggers: 391 logger_config_name = self.get_logger_config_name(logger, mode) 392 output[logger] = self.get_logger_config(logger_config_name) 393 394 return output
Retrieve the configs associated with a cruise id and mode from the data store. If mode is omitted, retrieve configs associated with the cruise's current logger configs.
397 def get_logger_config_name(self, logger_id, mode=None): 398 """ Retrieve name of the config associated with the specified logger 399 in the specified mode. If mode is omitted, retrieve name of logger's 400 current config. """ 401 402 config = self.get_configuration() 403 if not config: 404 return {} 405 loggers = config.get('loggers') 406 if loggers is None: 407 raise ValueError('No loggers found in config') 408 409 # No mode, so we want the active mode 410 if mode is None: 411 logger = loggers.get(logger_id) 412 if logger is None: 413 raise ValueError(f'Logger id {logger_id} has no mode!') 414 conf_name = logger.get('active') 415 if conf_name is not None: 416 return conf_name 417 418 # Mode given or no active conf, so get the default for this mode 419 modes = config.get('modes') 420 mode_configs = modes.get(mode) 421 if mode_configs is None: 422 raise ValueError(f'Requested mode {mode} is not defined') 423 logger_config_name = mode_configs.get(logger_id) 424 if logger_config_name is None: 425 raise ValueError(f'Logger {logger_id} has no config defined in mode {mode}') 426 return logger_config_name
Retrieve name of the config associated with the specified logger in the specified mode. If mode is omitted, retrieve name of logger's current config.
429 def get_logger_config_names(self, logger_id): 430 """ Retrieve list of config names that are valid for the 431 specified logger . 432 > api.get_logger_config_names('NBP1406', 'knud') 433 ["off", "knud->net", "knud->net/file", "knud->net/file/db"] 434 """ 435 logger = self.get_logger(logger_id) 436 return logger.get('configs', [])
Retrieve list of config names that are valid for the specified logger .
api.get_logger_config_names('NBP1406', 'knud') ["off", "knud->net", "knud->net/file", "knud->net/file/db"]
442 def set_active_mode(self, mode): 443 """Set the current mode of the specified cruise in the data store.""" 444 445 config = self.get_configuration() 446 modes = config.get('modes') 447 if not modes: 448 raise ValueError('Config has no modes') 449 if mode not in modes: 450 raise ValueError(f'Config has no mode "{mode}"') 451 452 self.config['active_mode'] = mode 453 454 # Update the API's working config's loggers 455 # to match the new mode 456 for logger, conf in modes[mode].items(): 457 self.config['loggers'][logger]['active'] = conf 458 459 self._save_config() 460 logging.info('Signaling update') 461 self.signal_update()
Set the current mode of the specified cruise in the data store.
464 def set_active_logger_config(self, logger, config_name): 465 """Set specified logger to new config. NOTE: we have no way to check 466 whether logger is compatible with config, so we rely on whoever is 467 calling us to have made that determination.""" 468 469 # self.logger_config[logger] = config_name 470 # NOTE: We can check that config_name is in logger[configs] 471 self.config['loggers'][logger]['active'] = config_name 472 self._save_config() 473 logging.info('Signaling update') 474 self.signal_update()
Set specified logger to new config. NOTE: we have no way to check whether logger is compatible with config, so we rely on whoever is calling us to have made that determination.
479 def update_status(self, status): 480 """Save/register the loggers' retrieved status report with the API.""" 481 self.status.append(((datetime.utcnow()), status)) 482 # NOTE(kped) Do we need to write this to the database? 483 # logger_manager never calls this....
Save/register the loggers' retrieved status report with the API.
489 def get_status(self, since_timestamp=None): 490 """Retrieve a dict of the most-recent status report from each 491 logger. If since_timestamp is specified, retrieve all status reports 492 since that time.""" 493 494 # Start by getting set of loggers for cruise. Store as 495 # cruise_id:logger for ease of lookup. 496 try: 497 logger_set = set([logger 498 for logger in self.get_loggers()]) 499 except ValueError: 500 logger_set = set() 501 502 logging.debug(f'logger_set: {logger_set}') 503 504 # Step backwards through status messages until we run out of 505 # status messages or reach termination condition. If 506 # since_timestamp==None, our termination is when we have a status 507 # for each of our loggers. If since_timestamp is a number, our 508 # termination is when we've grabbed all the statuses with a 509 # timestamp greater than the specified number. 510 status = {} 511 512 status_index = len(self.status) - 1 513 logging.debug(f'starting at status index {status_index}') 514 while logger_set and status_index >= 0: 515 # record is a dict of 'cruise_id:logger' : {fields} 516 (timestamp, record) = self.status[status_index] 517 logging.debug('%d: %f: %s', 518 status_index, timestamp, pprint.pformat(record)) 519 520 # If we've been given a numeric timestamp and we've stepped back 521 # in time to or before that timestamp, we're done - break out. 522 if since_timestamp is not None and timestamp <= since_timestamp: 523 break 524 525 # Otherwise, examine ids in this record to see if they're for 526 # the cruise in question. 527 for id, fields in record.items(): 528 # If id is cruise_id:logger that we're interested in, grab it. 529 logging.debug(f'Is {id} in {logger_set}?') 530 if id in logger_set: 531 if timestamp not in status: 532 status[timestamp] = {} 533 status[timestamp][id] = fields 534 535 # If since_timestamp==None, we only want the latest status 536 # for each logger. So once we've found it, remove the id 537 # from the logger_set we're lookings. We'll drop out of the 538 # loop when the set is empty. 539 if since_timestamp is None: 540 logger_set.discard(id) 541 status_index -= 1 542 543 return status
Retrieve a dict of the most-recent status report from each logger. If since_timestamp is specified, retrieve all status reports since that time.
548 def message_log(self, source, user, log_level, message): 549 """ Timestamp and store the passed message. """ 550 551 now = datetime.utcnow() 552 self.server_messages.append((now, source, user, 553 log_level, message)) 554 555 # Keep server_messages from over-eating memory 556 while len(self.server_messages) > 1000: 557 self.server_messages.pop(0) 558 559 Q = 'INSERT INTO logmessages \ 560 (timestamp, loglevel, cruise, source, user, message) \ 561 VALUES(?, ?, ?, ?, ?, ?)' 562 563 cruise = self.config.get('cruise', {}) 564 cruise_id = cruise.get('id', 'none') 565 566 self._sql_cmd(Q, now, log_level, cruise_id, source, user, message)
Timestamp and store the passed message.
569 def get_message_log(self, source=None, user=None, log_level=sys.maxsize, 570 since_timestamp=None): 571 """Retrieve log messages from source at or above log_level since 572 timestamp. If source is omitted, retrieve from all sources. If 573 log_level is omitted, retrieve at all levels. If since_timestamp is 574 omitted, only retrieve most recent message. 575 """ 576 577 # NOTE: Should we pull this from the database? 578 # No... if they want more history, look directly. 579 index = len(self.server_messages) - 1 580 messages = [] 581 while index >= 0: 582 message = self.server_messages[index] 583 (timestamp, mesg_source, mesg_user, 584 mesg_log_level, mesg_message) = message 585 # Have we gone back too far? If so, we're done. 586 if since_timestamp is not None and timestamp <= since_timestamp: 587 break 588 589 if mesg_log_level < log_level: 590 continue 591 if user and not mesg_user == user: 592 continue 593 if source and not mesg_source == source: 594 continue 595 596 messages.insert(0, message) 597 598 # Are we only looking for last message, and do we have a message? 599 if since_timestamp is None and messages: 600 break 601 index -= 1 602 603 return messages
Retrieve log messages from source at or above log_level since timestamp. If source is omitted, retrieve from all sources. If log_level is omitted, retrieve at all levels. If since_timestamp is omitted, only retrieve most recent message.
630 def load_configuration(self, config): 631 """Add a complete cruise configuration (id, modes, configs, 632 default) to the data store.""" 633 634 # Loaded new config, (optionally) backup old one 635 if DATABASE_BACKUPS: 636 self._backup_database() 637 638 self.config = config 639 # self.config['loaded_time'] = datetime.utcnow().isoformat() 640 self.config['loaded_time'] = datetime.utcnow() 641 642 # Some syntactic sugar to simplify config definitions 643 configs = self.config.get('configs') 644 for config_name, config in configs.items(): 645 if config is None: 646 raise ValueError(f'No logger for "{config_name}" in cruise definition') 647 if 'name' not in config: 648 self.config['configs'][config_name]['name'] = config_name 649 650 # Set cruise into default mode, if one is defined 651 if 'default_mode' in self.config: 652 active_mode = self.config['default_mode'] 653 self.set_active_mode(active_mode) 654 else: 655 logging.warn('Cruise has no default mode') 656 # Why not send the entire config to the CDS? Why 657 # just *almost* all of it? JSON issue? 658 cruise = self.config.get('cruise') 659 if cruise: 660 for key in ['id', 'start', 'end']: 661 if key not in self.config: 662 self.config[key] = cruise.get(key) 663 self._save_config() 664 self.signal_load()
Add a complete cruise configuration (id, modes, configs, default) to the data store.
667 def delete_configuration(self): 668 """Remove the specified cruise from the data store.""" 669 self.config = {} 670 # self.logger_config = {} 671 self.callbacks = [] 672 self.status = [] 673 self._save_config()
Remove the specified cruise from the data store.
Add a new mode to the OpenRVDAS configuration.
api.add_mode('underway')
680 def delete_mode(self, cruise_id, mode): 681 logging.warn('Method "delete_mode" not implemented')
Delete the named mode (and all its configs) from the data store. If the deleted mode is the active mode, set the active mode to the default mode.
api.delete_mode('underway')
683 def add_logger(self, cruise_id, logger_id, logger_spec): 684 logging.warn('Method "add_logger" not implemented')
Add a new logger to the data store.
logger_config - a dict defining: host - optional restriction on which host logger must run configs - list of logger_config names
api.add_logger(gyr2', { 'host_id':
, 'configs': [....] })
686 def delete_logger(self, cruise_id, logger_id): 687 logging.warn('Method "delete_logger" not implemented')
Remove a logger and all its associated logger_configs from the data store.
api.delete_logger(gyr2')