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')
DEFAULT_MAX_TRIES = 3
ID_SEPARATOR = ':'
DATABASE_BACKUPS = True
DATABASE_COMPRESS = True
DIR_PATH = '/home/runner/work/openrvdas/openrvdas/server'
DEFAULT_DATABASE_PATH = '/home/runner/work/openrvdas/openrvdas/server/openrvdas.sql'
EPOCH_TIME_ZERO = datetime.datetime(1970, 1, 1, 0, 0)
class SQLiteServerAPI(server.server_api.ServerAPI):
 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 }
  }
}
SQLiteServerAPI( database_path='/home/runner/work/openrvdas/openrvdas/server/openrvdas.sql', no_create_database=False)
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.

database_path
no_create_database
config
callbacks
status
server_messages
cx
timestamp
def get_configuration(self):
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.

def get_modes(self):
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.

def get_active_mode(self):
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.

def get_default_mode(self):
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.

def get_logger(self, logger):
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.

def get_loggers(self):
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':[,,...], 'active':},...} for all loggers.

def get_logger_config(self, config_name):
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.

def get_logger_configs(self, mode=None):
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.

def get_logger_config_name(self, logger_id, mode=None):
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.

def get_logger_config_names(self, logger_id):
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"]

def set_active_mode(self, mode):
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.

def set_active_logger_config(self, logger, config_name):
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.

def update_status(self, status):
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.

def get_status(self, since_timestamp=None):
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.

def message_log(self, source, user, log_level, message):
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.

def get_message_log( self, source=None, user=None, log_level=9223372036854775807, since_timestamp=None):
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.

def load_configuration(self, config):
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.

def delete_configuration(self):
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.

def add_mode(self, cruise_id, mode):
677    def add_mode(self, cruise_id, mode):
678        logging.warn('Method "add_mode" not implemented')

Add a new mode to the OpenRVDAS configuration.

api.add_mode('underway')

def delete_mode(self, cruise_id, mode):
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')

def add_logger(self, cruise_id, logger_id, logger_spec):
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': [....] })

def delete_logger(self, cruise_id, logger_id):
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')

def add_config(self, cruise_id, config, config_spec):
689    def add_config(self, cruise_id, config, config_spec):
690        logging.warn('Method "add_config" not implemented')
def add_config_to_logger(self, cruise_id, config, logger_id):
692    def add_config_to_logger(self, cruise_id, config, logger_id):
693        logging.warn('Method "add_config_to_logger" not implemented')
def add_config_to_mode(self, cruise_id, config, logger_id, mode):
695    def add_config_to_mode(self, cruise_id, config, logger_id, mode):
696        logging.warn('Method "add_config_to_mode" not implemented')
def delete_config(self, cruise_id, config_id):
698    def delete_config(self, cruise_id, config_id):
699        logging.warn('Method "delete_config" not implemented')