-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathdb_sqlite.py
More file actions
1695 lines (1430 loc) · 62.7 KB
/
Copy pathdb_sqlite.py
File metadata and controls
1695 lines (1430 loc) · 62.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#! /usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author: Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
# https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.
# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md
"""Library for accessing SQLite databases.
This is one typical use case of this library (taken from `disk-io`):
>>> conn = lib.base.coe(lib.db_sqlite.connect(filename='disk-io.db'))
>>> lib.base.coe(lib.db_sqlite.create_table(conn, definition, drop_table_first=False))
>>> lib.base.coe(lib.db_sqlite.create_index(conn, 'name')) # optional
>>> lib.base.coe(lib.db_sqlite.insert(conn, data))
>>> lib.base.coe(lib.db_sqlite.cut(conn, max=args.COUNT * len(disks)))
>>> lib.base.coe(lib.db_sqlite.commit(conn))
>>> result = lib.base.coe(lib.db_sqlite.select(conn,
'SELECT * FROM perfdata WHERE name = :name ORDER BY timestamp DESC LIMIT 2',
{'name': disk}
>>> lib.db_sqlite.close(conn)
"""
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026080502'
import csv
import functools
import hashlib
import os
import re
import sqlite3
import stat
from . import disk, time, txt
# Substrings identifying an `sqlite3.OperationalError` that means the on-disk schema no longer
# matches what this release reads or writes, for example because a plugin gained or lost a column
# between two versions. Discarding the database is the correct recovery here: the next run
# rebuilds a valid cache from scratch.
#
# `OperationalError` covers far more than that, though. A lock held by a concurrent plugin run
# ("database is locked"), a full or read-only disk, an I/O error or a broken SQL statement all
# raise it as well, and deleting the file there destroys a healthy cache, possibly one another
# process is still using. Those errors are reported to the caller and leave the database alone.
#
# "no such table" is deliberately absent: a table that does not exist yet is the normal state on
# the first run, and `create_table()` uses `IF NOT EXISTS`, so nothing has to be discarded to
# recover. Matching it would let one plugin's first query wipe the tables of every other plugin
# sharing the default database file.
SCHEMA_ERRORS = (
'has no column named',
'no such column',
'values were supplied', # "table t has 2 columns but 3 values were supplied"
)
# Substrings matched before `SCHEMA_ERRORS` and always treated as harmless. A WITHOUT ROWID table
# has no `rowid` column, so `cut()` fails against one with "no such column: rowid". That schema is
# what the caller asked for, not a mismatch between releases, but the `no such column` entry above
# would otherwise match it and delete a perfectly healthy database.
HEALTHY_SCHEMA_ERRORS = ('no such column: rowid',)
# Substrings identifying an `sqlite3.IntegrityError` (SQLITE_CONSTRAINT, SQLITE_MISMATCH) that
# means the on-disk schema no longer matches the data being written, for example a NOT NULL column
# a newer or older release no longer fills.
#
# The other constraint violations are not schema problems at all. A UNIQUE or PRIMARY KEY conflict
# is an ordinary data condition that a plugin hits on a healthy cache, and `replace()` exists to
# resolve exactly that; CHECK and FOREIGN KEY violations and "datatype mismatch" say something
# about the row, not about the file.
INTEGRITY_SCHEMA_ERRORS = ('not null constraint failed',)
# Substrings identifying a database file that is unusable no matter what is queried. sqlite3
# reports these as a plain `sqlite3.DatabaseError`, not as an `OperationalError`, so the case
# where discarding the file is most clearly right needs to be matched separately.
CORRUPT_ERRORS = (
'database disk image is malformed',
'file is not a database',
'malformed database schema',
)
def __filter_str(s, charclass='a-zA-Z0-9_'):
"""
Filter a string to keep only allowed characters.
This function removes all characters from a string except those matching the allowed
character class. By default, it allows only alphanumeric characters (`a-z`, `A-Z`, `0-9`)
and underscores (`_`), making the output safe for use in variable names, table names,
index names, and similar identifiers.
### Parameters
- **s** (`str`):
The input string to sanitize.
- **charclass** (`str`, optional):
A regex character class defining allowed characters.
Defaults to `'a-zA-Z0-9_'`.
### Returns
- **str**:
A sanitized string containing only characters matching the allowed character class.
### Notes
- Useful for cleaning user input before using it in database object names or variable names.
- The function uses regular expressions for filtering.
### Example
>>> __filter_str('user@example.ch')
'userexamplech'
>>> __filter_str('project-123', charclass='a-zA-Z0-9')
'project123'
"""
regex = f'[^{charclass}]'
return re.sub(regex, '', s)
def __sha1sum(string):
"""
Calculate the SHA-1 hash of a given string.
This function encodes the input as bytes (if necessary) and returns its SHA-1 checksum
as a hexadecimal string.
### Parameters
- **string** (`str`):
The input string to hash.
### Returns
- **str**:
The SHA-1 hash of the input string, represented as a 40-character hexadecimal string.
### Notes
- Internally, the input is safely converted to bytes before hashing using `txt.to_bytes()`.
- SHA-1 produces a fixed-size 160-bit (20-byte) hash, commonly used for checksums and
identifiers.
### Example
>>> __sha1sum('linuxfabrik')
'74301e766db4a4006ec1fbd6e031760e7e322223'
"""
return hashlib.sha1(txt.to_bytes(string), usedforsecurity=False).hexdigest()
@functools.lru_cache(maxsize=128)
def __compile_regex(expr):
"""
Compile and cache a regular expression used by the `REGEXP` SQL function.
A `REGEXP` comparison is evaluated once per row, always with the same pattern. Caching the
compiled pattern mirrors what SQLite's own `regexp()` implementation does by stashing the
compiled expression via `sqlite3_set_auxdata()`.
### Parameters
- **expr** (`str`):
The regular expression pattern.
### Returns
- **re.Pattern**:
The compiled pattern.
### Example
>>> __compile_regex('^abc').search('abcdef') is not None
True
"""
return re.compile(expr)
def __quote_ident(name):
"""
Quote an SQL identifier (a table, index or column name) for use in a statement.
Values are always passed as bind parameters, but identifiers cannot be bound and have to be
interpolated into the statement text. Quoting them makes an identifier that contains SQL
syntax inert instead of executable, and additionally allows names that are SQLite keywords
(`select`) or start with a digit.
### Parameters
- **name** (`str`):
The identifier to quote.
### Returns
- **str**:
The identifier wrapped in double quotes, with embedded double quotes doubled.
### Example
>>> __quote_ident('perfdata')
'"perfdata"'
>>> __quote_ident('a) VALUES (99); --')
'"a) VALUES (99); --"'
"""
escaped = str(name).replace('"', '""')
return f'"{escaped}"'
def __quote_ident_list(column_list):
"""
Quote every column of a comma-separated column list.
### Parameters
- **column_list** (`str`):
A comma-separated list of column names, for example `'col1, col2'`.
### Returns
- **str**:
The same list with every column quoted, for example `'"col1","col2"'`.
### Example
>>> __quote_ident_list('host_id, service_id')
'"host_id","service_id"'
"""
return ','.join(
__quote_ident(col.strip()) for col in column_list.split(',') if col.strip()
)
def __table_columns(conn, table):
"""
Return the column names of `table` as reported by the database itself.
Used to reject a column name before it reaches a statement. SQLite resolves a double-quoted
token that matches no column to a string literal instead of raising "no such column" (the
double-quoted string misfeature, see `resolveExprStep()` in SQLite's `resolve.c`). A
misspelled column would therefore index or group by a constant, silently and successfully.
### Parameters
- **conn** (`sqlite3.Connection`):
An active database connection object.
- **table** (`str`):
Name of the table to inspect.
### Returns
- **list** of `str`:
The column names, or an empty list if the table does not exist or cannot be inspected.
### Example
>>> __table_columns(conn, 'perfdata')
['name', 'timestamp', 'rx_bytes']
"""
# `table_xinfo` rather than `table_info`: the latter omits generated columns, which SQLite
# selects and indexes like any other column, so validating against it would reject a name that
# is perfectly usable. `table_xinfo` needs SQLite 3.26.0, hence the fallback.
# Both pragmas yield (cid, name, type, notnull, dflt_value, pk), `table_xinfo` plus `hidden`.
for pragma in ('table_xinfo', 'table_info'):
try:
rows = conn.execute(f'PRAGMA {pragma}({__quote_ident(table)});').fetchall()
except sqlite3.Error:
continue
return [row[1] for row in rows]
return []
def __is_unusable_db(e):
"""
Decide whether an `sqlite3` exception means the database file has to be discarded.
Only a schema that no longer matches this release, or an unreadable file, justify deleting the
database. Everything else (a lock held by a concurrent plugin run, a full or read-only disk, an
I/O error, a broken statement, a failing user-defined function, a value the caller may not
store, a row that violates a constraint) is transient, a caller bug or ordinary data: the cache
is fine and has to survive.
### Parameters
- **e** (`Exception`):
The exception raised by the failed statement.
### Returns
- **bool**:
`True` if the database file is unusable and should be removed, `False` otherwise.
### Example
>>> __is_unusable_db(sqlite3.OperationalError('no such column: foo'))
True
>>> __is_unusable_db(sqlite3.OperationalError('database is locked'))
False
>>> __is_unusable_db(sqlite3.IntegrityError('UNIQUE constraint failed: t.a'))
False
"""
msg = str(e).lower()
# `sqlite3.DataError` is raised for SQLITE_TOOBIG only ("string or blob too big"). That is a
# value the caller must not store, and says nothing about the file. Checked before
# `DatabaseError`, of which it is a subclass.
if isinstance(e, sqlite3.DataError):
return False
if isinstance(e, sqlite3.IntegrityError):
return any(pattern in msg for pattern in INTEGRITY_SCHEMA_ERRORS)
if isinstance(e, sqlite3.OperationalError):
if any(pattern in msg for pattern in HEALTHY_SCHEMA_ERRORS):
return False
return any(pattern in msg for pattern in SCHEMA_ERRORS)
if isinstance(e, sqlite3.DatabaseError):
return any(pattern in msg for pattern in CORRUPT_ERRORS)
return False
def __handle_db_error(conn, e, sql, data=None, delete_db=True):
"""
Turn an `sqlite3` exception into this library's error tuple, deleting the database first if
the file turned out to be unusable.
### Parameters
- **conn** (`sqlite3.Connection`):
The connection the statement was executed on.
- **e** (`Exception`):
The exception raised by the failed statement.
- **sql** (`str`):
The statement that failed, included in the error message.
- **data** (`dict` or `tuple`, optional):
The bind parameters of the failed statement, included in the error message when given.
- **delete_db** (`bool`, optional):
Whether deleting an unusable database file is allowed at all. Defaults to `True`.
### Returns
- **tuple** (`bool`, `str`):
Always `False` plus an error message describing the failure.
### Notes
- The message wording is part of this library's contract: the plugin documentation and
several plugin unit tests match on `Operational Error: <sqlite message>, Query: ...`.
"""
if delete_db and __is_unusable_db(e):
rm_db(conn)
suffix = '' if data is None else f', Data: {data}'
if isinstance(e, sqlite3.OperationalError):
return False, f'Operational Error: {e}, Query: {sql}{suffix}'
if isinstance(e, (sqlite3.DataError, sqlite3.IntegrityError)):
return False, f'Integrity Error: {e}, Query: {sql}{suffix}'
return False, f'Query failed: {sql}, Error: {e}{suffix}'
def close(conn):
"""
Close a SQLite database connection safely.
This function attempts to close an open database connection.
It does not automatically commit any uncommitted changes — if you close the connection
without calling `commit()` first, any uncommitted changes will be lost.
### Parameters
- **conn** (`sqlite3.Connection` or compatible):
An active database connection object.
### Returns
- **bool**:
- `True` if the connection was closed successfully.
- `False` if an exception occurred during closing.
### Notes
- Always call `commit()` manually before calling `close()` if you want to save changes.
- Exceptions during closing are caught and handled silently.
### Example
>>> close(conn)
True
"""
try:
conn.close()
return True
except Exception:
return False
def commit(conn):
"""
Commit any pending changes to the SQLite database.
This function saves (commits) all changes made during the current database session.
If committing fails, an error message is returned.
### Parameters
- **conn** (`sqlite3.Connection` or compatible):
An active database connection object.
### Returns
- **tuple** (`bool`, `str or None`):
- First element (`bool`): `True` if the commit succeeded, `False` if it failed.
- Second element (`str` or `None`):
- `None` on success.
- Error message (`str`) describing the failure if commit fails.
### Notes
- Always commit before closing the connection if you want to preserve changes.
- Exceptions during commit are caught and returned as part of the result.
### Example
>>> success, error = commit(conn)
>>> if not success:
>>> print(error)
>>> else:
>>> print("Changes committed successfully.")
"""
try:
conn.commit()
return True, None
except Exception as e:
return False, f'Commit failed: {e}'
def compute_load(conn, sensorcol, datacols, count, table='perfdata'):
"""
Calculate per-second load metrics based on historical data in a SQLite table.
This function calculates `Load1` (over the last 1 interval) and `Loadn` (over the last `count` intervals)
for one or more sensors, based on timestamped performance data.
### Parameters
- **conn** (`sqlite3.Connection`):
An active database connection object.
- **sensorcol** (`str`):
Column name that identifies the sensor (e.g., `'interface'`).
- **datacols** (`list` of `str`):
List of columns for which to calculate per-second loads (e.g., `['tx_bytes', 'rx_bytes']`).
- **count** (`int`):
Number of historical entries to use for calculating `Loadn`.
- **table** (`str`, optional):
Name of the table containing the performance data.
Defaults to `'perfdata'`.
### Returns
- **tuple** (`bool`, `list or bool or str`):
- First element (`bool`): `True` if the calculation succeeded, `False` if a database error occurred.
- Second element:
- A `list` of dictionaries containing per-sensor load values on success.
- `False` if there is not enough data to compute the load.
- Error message (`str`) on database failure.
### Notes
- The table must contain a `timestamp` column (UNIX epoch seconds).
- Data must exist for each sensor with at least `count` historical entries.
- Results include:
- `<column>1`: Load computed between the two most recent entries.
- `<column>n`: Load computed between the most recent and the oldest of `count` entries.
- Load values are calculated as delta per second.
- The table name is quoted, so keywords and names containing punctuation work.
### Example
Calculate loads for `tx_bytes` and `rx_bytes` over 5 intervals:
>>> compute_load(
... conn,
... sensorcol='interface',
... datacols=['tx_bytes', 'rx_bytes'],
... count=5,
... table='perfdata',
... )
Example output:
[
{
'interface': 'mgmt1',
'tx_bytes1': 6906,
'rx_bytes1': 10418,
'tx_bytesn': 7442,
'rx_bytesn': 10871
},
...
]
"""
# See __table_columns(): an unknown sensor column would silently become a string literal
# instead of raising, so every sensor would look identical.
known = __table_columns(conn, table)
if known and sensorcol not in known:
return False, f'No such column {sensorcol} in table {table}'
quoted_table = __quote_ident(table)
quoted_sensorcol = __quote_ident(sensorcol)
sql = (
f'SELECT DISTINCT {quoted_sensorcol} FROM {quoted_table} ' # nosec B608
f'ORDER BY {quoted_sensorcol} ASC;'
)
success, sensors = select(conn, sql)
if not success:
return False, sensors
if len(sensors) == 0:
return True, False
load = []
for sensor in sensors:
sensor_name = sensor[sensorcol]
# A fixed bind name, not the column name: `sensorcol` may legitimately contain characters
# that are not valid in a `:placeholder`.
success, perfdata = select(
conn,
f'SELECT * FROM {quoted_table} WHERE {quoted_sensorcol} = :sensorvalue ' # nosec B608
f'ORDER BY timestamp DESC;',
data={'sensorvalue': sensor_name},
)
if not success:
return False, perfdata
if len(perfdata) < count:
return True, False
load1_delta = perfdata[0]['timestamp'] - perfdata[1]['timestamp']
loadn_delta = perfdata[0]['timestamp'] - perfdata[count - 1]['timestamp']
tmp = {sensorcol: sensor_name}
for key in datacols:
if key in perfdata[0]:
tmp[f'{key}1'] = (
(perfdata[0][key] - perfdata[1][key]) / load1_delta
if load1_delta
else 0
)
tmp[f'{key}n'] = (
(perfdata[0][key] - perfdata[count - 1][key]) / loadn_delta
if loadn_delta
else 0
)
load.append(tmp)
return True, load
def connect(path='', filename='', timeout=5.0):
"""
Connect to a SQLite database file.
This function establishes a connection to a SQLite database file.
If no path is provided, a temporary directory is used.
If no filename is provided, the default filename `'linuxfabrik-monitoring-plugins-sqlite.db'`
is used.
### Parameters
- **path** (`str`, optional):
Path to the directory containing the database file.
Defaults to the system temporary directory (e.g., `/tmp`).
- **filename** (`str`, optional):
Name of the database file.
Defaults to `'linuxfabrik-monitoring-plugins-sqlite.db'`.
- **timeout** (`float`, optional):
Seconds to wait for a lock held by another process before giving up with
`database is locked`. Defaults to `5.0`. Raise it when several checks share one database
file and run concurrently.
### Returns
- **tuple** (`bool`, `Connection or str`):
- First element (`bool`): `True` if connection succeeded, `False` if it failed.
- Second element (`Connection` or `str`):
- Database connection object on success.
- Error message string on failure.
### Notes
- On POSIX systems the database is stored in a per-user, `0700`-protected subdirectory of the
temporary directory (see `get_db_dir()`), not directly in the shared, world-writable `/tmp`.
This isolates each user's databases and prevents symlink attacks on the predictable paths
(CWE-377, GHSA-r35r-fpx2-jgr4).
- The connection uses a `Row` factory, allowing rows to behave like dictionaries.
- The connection registers a `REGEXP` SQL function for regular expression support.
- Always check the returned success flag before using the connection.
### Example
>>> success, conn = connect()
>>> if success:
>>> # Use conn
>>> pass
>>> else:
>>> print(conn)
"""
success, db = get_db_path(path=path, filename=filename)
if not success:
return False, db
try:
conn = sqlite3.connect(db, timeout=timeout)
conn.row_factory = sqlite3.Row
conn.text_factory = str
# `deterministic=True`: the same pattern and string always yield the same result, so
# SQLite may use REGEXP in partial indexes and generated columns, and may cache results.
conn.create_function('REGEXP', 2, regexp, deterministic=True)
return True, conn
except Exception as e:
return False, f'Connecting to DB {db} failed, Error: {e}'
def create_index(
conn,
column_list,
table='perfdata',
unique=False,
delete_db_on_operational_error=True,
):
"""
Create an index on one or more columns in a SQLite table.
This function creates a (unique or non-unique) index on the specified columns of a table.
If the database structure has changed and an `OperationalError` occurs, the database file
can optionally be deleted automatically.
### Parameters
- **conn** (`sqlite3.Connection`):
An active database connection object.
- **column_list** (`str`):
A comma-separated list of columns to index, for example `'col1, col2'`.
- **table** (`str`, optional):
The table name. Defaults to `'perfdata'`.
- **unique** (`bool`, optional):
If `True`, creates a unique index.
If `False`, creates a standard (non-unique) index. Defaults to `False`.
- **delete_db_on_operational_error** (`bool`, optional):
If `True`, deletes the database file when the on-disk database turns out
to be unusable (e.g. a schema mismatch between releases).
Defaults to `True`.
### Returns
- **tuple** (`bool`, `bool or str`):
- First element (`bool`): `True` if the operation succeeded, `False` if it failed.
- Second element (`bool` or `str`):
- `True` on success.
- Error message (`str`) describing the failure.
### Notes
- The table name is sanitized to only allow safe characters.
- The index name is automatically generated as `idx_<sha1sum>`, based on table and column names.
- Index creation uses `IF NOT EXISTS` to avoid errors if the index already exists.
### Example
>>> create_index(conn, 'hostname, service')
(True, True)
>>> create_index(conn, 'timestamp', table='logs', unique=True)
(True, True)
"""
requested = [col.strip() for col in column_list.split(',') if col.strip()]
# An unknown column would be quoted into the statement and then resolved to a string literal
# by SQLite instead of raising, leaving a useless index over a constant behind. Only validate
# when the table is already there; otherwise let SQLite report "no such table" itself.
known = __table_columns(conn, table)
if known:
unknown = [col for col in requested if col not in known]
if unknown:
return False, (
f'Cannot index unknown column(s) {", ".join(unknown)} of table {table}'
)
# Normalize the column list before hashing it, so 'a, b' and 'a,b' describe the same index
# instead of creating two identical ones.
columns = ','.join(requested)
index_name = f'idx_{__sha1sum(table + columns)}'
unique_kw = 'UNIQUE ' if unique else ''
sql = (
f'CREATE {unique_kw}INDEX IF NOT EXISTS {__quote_ident(index_name)} '
f'ON {__quote_ident(table)} ({__quote_ident_list(column_list)});'
)
c = conn.cursor()
try:
c.execute(sql)
return True, True
except sqlite3.Error as e:
return __handle_db_error(conn, e, sql, delete_db=delete_db_on_operational_error)
except Exception as e:
return False, f'Query failed: {sql}, Error: {e}'
def create_table(conn, definition, table='perfdata', drop_table_first=False):
"""
Create a database table if it does not exist.
This function creates a table in the SQLite database based on the given column definition.
Optionally, the table can be dropped first if it already exists.
### Parameters
- **conn** (`sqlite3.Connection`):
An active database connection object.
- **definition** (`str`):
Column definitions for the table, e.g., `'col1 TEXT, col2 INTEGER NOT NULL'`.
- **table** (`str`, optional):
Name of the table to create. Defaults to `'perfdata'`.
- **drop_table_first** (`bool`, optional):
If `True`, drops the table before creating it. Defaults to `False`.
### Returns
- **tuple** (`bool`, `bool or str`):
- First element (`bool`): `True` if the table was created successfully, `False` if an
error occurred.
- Second element (`bool` or `str`):
- `True` on success.
- Error message (`str`) describing the failure.
### Notes
- The table name is quoted, so keywords and names containing punctuation work.
- If `drop_table_first=True`, the function will attempt to drop the existing table before
creating it.
- The table creation uses `IF NOT EXISTS` to avoid errors if the table already exists.
### Example
Create a new table with three columns:
>>> create_table(conn, 'a TEXT, b TEXT, c INTEGER NOT NULL', table='test')
Resulting SQL:
CREATE TABLE IF NOT EXISTS "test" (a TEXT, b TEXT, c INTEGER NOT NULL);
"""
if drop_table_first:
success, result = drop_table(conn, table)
if not success:
return success, result
sql = f'CREATE TABLE IF NOT EXISTS {__quote_ident(table)} ({definition});'
c = conn.cursor()
try:
c.execute(sql)
return True, True
except Exception as e:
return False, f'Query failed: {sql}, Error: {e}'
def cut(conn, table='perfdata', _max=5, delete_db_on_operational_error=True):
"""
Keep only the latest records in a SQLite table, based on `rowid`.
This function deletes older rows from a table, keeping only the most recent `_max` entries
according to the SQLite built-in `rowid`. Useful for maintaining lightweight, capped tables.
### Parameters
- **conn** (`sqlite3.Connection`):
An active database connection object.
- **table** (`str`, optional):
Name of the table to prune. Defaults to `'perfdata'`.
- **_max** (`int`, optional):
Number of most recent records to keep. Defaults to `5`.
- **delete_db_on_operational_error** (`bool`, optional):
If `True`, deletes the database file when the on-disk database turns out
to be unusable (e.g. a schema mismatch between releases).
Defaults to `True`.
### Returns
- **tuple** (`bool`, `bool or str`):
- First element (`bool`): `True` if deletion succeeded, `False` if it failed.
- Second element (`bool` or `str`):
- `True` on success.
- Error message (`str`) describing the failure.
### Notes
- The function relies on the implicit `rowid` column for ordering. A `WITHOUT ROWID` table has
no such column and cannot be pruned this way; the call reports `no such column: rowid` and
leaves the database alone.
- The table name is quoted, so keywords and names containing punctuation work.
- If an `OperationalError` occurs (e.g., due to schema mismatch), the database file can
be deleted automatically.
- Uses `LIMIT -1 OFFSET :_max` to delete everything after the most recent `_max` records.
### Example
>>> cut(conn, table='logs', _max=1000)
(True, True)
"""
table = __quote_ident(table)
# `LIMIT -1` means "no limit" (see SQLite's select.c), so the subquery yields every row after
# the `_max` most recent ones.
# `table` is quoted above, `_max` is bound.
sql = f"""
DELETE FROM {table}
WHERE rowid IN (
SELECT rowid FROM {table}
ORDER BY rowid DESC
LIMIT -1 OFFSET :_max
);
""" # nosec B608
c = conn.cursor()
try:
c.execute(sql, {'_max': _max})
return True, True
except sqlite3.Error as e:
return __handle_db_error(conn, e, sql, delete_db=delete_db_on_operational_error)
except Exception as e:
return False, f'Query failed: {sql}, Error: {e}'
def delete(conn, sql, data=None, delete_db_on_operational_error=True):
"""
Execute a DELETE command against a SQLite table.
This function deletes records from a table based on the given SQL DELETE statement.
If no WHERE clause is provided, all records are deleted.
Parameter binding is supported for safety.
### Parameters
- **conn** (`sqlite3.Connection`):
An active database connection object.
- **sql** (`str`):
The SQL DELETE statement to execute.
Use placeholders (`:key`) for parameterized queries.
- **data** (`dict`, optional):
Dictionary of parameters to bind to the SQL statement.
Defaults to an empty dict (no parameters).
- **delete_db_on_operational_error** (`bool`, optional):
If `True`, deletes the database file when the on-disk database turns out
to be unusable (e.g. a schema mismatch between releases).
Defaults to `True`.
### Returns
- **tuple** (`bool`, `int or str`):
- First element (`bool`): `True` if the delete succeeded, `False` if it failed.
- Second element (`int` or `str`):
- Number of rows affected (`int`) on success.
- Error message (`str`) on failure.
### Notes
- If the WHERE clause is omitted, all rows in the table will be deleted.
- Always use a WHERE clause carefully to avoid unintended full table deletion.
- On schema-related `OperationalError`, the database file can be deleted automatically.
### Example
Delete records older than a specific timestamp:
>>> sql = 'DELETE FROM logs WHERE timestamp < :cutoff'
>>> data = {'cutoff': 1700000000}
>>> delete(conn, sql, data)
(True, 42)
"""
if data is None:
data = {}
c = conn.cursor()
try:
rowcount = c.execute(sql, data).rowcount if data else c.execute(sql).rowcount
return True, rowcount
except sqlite3.Error as e:
return __handle_db_error(
conn, e, sql, data=data, delete_db=delete_db_on_operational_error
)
except Exception as e:
return False, f'Query failed: {sql}, Error: {e}, Data: {data}'
def drop_table(conn, table='perfdata'):
"""
Drop a table from the SQLite database.
This function removes a table and all associated indices and triggers from the database.
If the table does not exist, no error is raised.
### Parameters
- **conn** (`sqlite3.Connection`):
An active database connection object.
- **table** (`str`, optional):
Name of the table to drop.
Defaults to `'perfdata'`.
### Returns
- **tuple** (`bool`, `bool or str`):
- First element (`bool`): `True` if the operation succeeded, `False` if an error occurred.
- Second element (`bool` or `str`):
- `True` on success.
- Error message (`str`) describing the failure.
### Notes
- The table name is quoted, so keywords and names containing punctuation work.
- Dropping a table is permanent: all table data, indices, and triggers are permanently deleted.
- The statement uses `DROP TABLE IF EXISTS` to avoid errors if the table is missing.
### Example
>>> drop_table(conn, table='logs')
(True, True)
"""
sql = f'DROP TABLE IF EXISTS {__quote_ident(table)};'
c = conn.cursor()
try:
c.execute(sql)
return True, True
except Exception as e:
return False, f'Query failed: {sql}, Error: {e}'
def get_colnames(col_definition):
"""
Extract a list of column names from a SQL column definition.
This function parses a SQL-style column definition string and returns a list
of column names, ignoring types and constraints.
### Parameters
- **col_definition** (`str`):
A string defining columns in SQL format, e.g., `'col1 TEXT, col2 INTEGER NOT NULL'`.
### Returns
- **list** (`list` of `str`):
A list of extracted column names.
### Notes
- Only the first word of each column definition is considered the column name.
- Column constraints (`PRIMARY KEY`, `NOT NULL`) and data types are ignored.
- Splitting happens on top-level commas only, so a comma inside a type (`DECIMAL(10,2)`) or
inside a quoted default value does not start a new column.
- Table-level constraints (`PRIMARY KEY (a, b)`, `UNIQUE`, `CHECK`, `FOREIGN KEY`,
`CONSTRAINT`) are not columns and are skipped.
- Quoted column names are returned unquoted.
### Example
>>> get_colnames('date TEXT PRIMARY KEY, count FLOAT, name TEXT')
['date', 'count', 'name']
>>> get_colnames('id INT, price DECIMAL(10,2), PRIMARY KEY (id, price)')
['id', 'price']
"""
# Table constraints share the column-definition list but do not name a column.
table_constraints = ('CHECK', 'CONSTRAINT', 'FOREIGN', 'PRIMARY', 'UNIQUE')
quotes = {'"': '"', "'": "'", '`': '`', '[': ']'}
parts = []
current = ''
depth = 0
closing = None
for char in col_definition:
if closing:
current += char
if char == closing:
closing = None
continue
if char in quotes:
closing = quotes[char]
current += char
continue
if char == '(':
depth += 1
elif char == ')':
depth -= 1
elif char == ',' and depth == 0:
parts.append(current)
current = ''
continue
current += char
parts.append(current)
colnames = []
for part in parts:
part = part.strip()
if not part:
continue
if part[0] in quotes:
# A quoted name may contain spaces, so it cannot be taken apart with split().
end = part.find(quotes[part[0]], 1)
colnames.append(part[1:end] if end > 0 else part[1:])
continue
name = part.split()[0]
if name.upper() in table_constraints:
continue
colnames.append(name)
return colnames
def get_db_dir(path):
"""
Return a per-user subdirectory of `path` that is safe for storing SQLite databases,
creating it if necessary.
SQLite databases are stored at predictable paths under the system temporary directory so the
data is found again on the next run. On a shared POSIX `/tmp`, that predictable path lets a
local attacker pre-create a symlink there and redirect writes to an arbitrary file. For a
process running as root (e.g. via sudo) this turns into an arbitrary-write primitive (CWE-377,
GHSA-r35r-fpx2-jgr4). To prevent this, all databases are kept inside a directory owned by the
current user with `0700` permissions, and the directory is rejected if anything about it looks
tampered with.
### Parameters
- **path** (`str`):
The base directory (typically the system temporary directory) in which to create the
per-user subdirectory.
### Returns
- **tuple** (`bool`, `str`):
- First element (`bool`): `True` on success, `False` on failure.
- Second element (`str`):
- The absolute path to the secure subdirectory on success.
- An error message describing the failure otherwise.
### Notes
- `os.geteuid()` does not exist on Windows, where the temporary directory is already per-user
rather than a shared, world-writable location. There the base `path` is returned unchanged.
- The directory is validated with `os.lstat()` so a symlink planted at its path is detected
instead of being followed.
### Example
>>> get_db_dir('/tmp')
(True, '/tmp/linuxfabrik-monitoring-plugins-uid1000')
"""
# On Windows the temp dir is already per-user; the shared-/tmp hardening below does not apply.
if not hasattr(os, 'geteuid'):
return True, path
euid = os.geteuid()
db_dir = os.path.join(path, f'linuxfabrik-monitoring-plugins-uid{euid}')